Verify webhook signatures
Every webhook and tool request ThunderPhone sends is signed. Verify the signature once with the recipe here, then reuse the same check on every endpoint you run.
Every request we send to your server — webhook deliveries and
tool-endpoint invocations — carries an HMAC-SHA256 signature in the
X-ThunderPhone-Signature header. Get the verification right once and
plug the same helper into every handler.
The algorithm
- Read the raw request body — the exact bytes we POSTed to you.
- Compute
hmac_sha256(secret, body).hexdigest(). - Compare in constant time against
X-ThunderPhone-Signature. (Naive string compare leaks timing information.)
We sign exactly the bytes we transmit, so verifying the raw body
always works. Those bytes are also the canonical JSON serialization
of the payload — keys sorted alphabetically, compact separators
(, and : with no spaces), UTF-8. That gives you a second, fully
equivalent recipe when your framework only exposes parsed JSON:
re-serialize canonically and HMAC that.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Prefer the raw body — it's one less step and immune to JSON number round-tripping quirks in some languages.
Which secret?
| Source | Secret |
|---|---|
Webhook endpoint (/v1/developer/webhook-endpoints) | Per-endpoint secret (48 hex chars) returned once at create |
| Legacy single-URL webhook | Per-org secret returned on GET /v1/webhook |
Tool-endpoint invocation (direct call to your endpoint.url) | The org-level webhook secret (the same one as the legacy single-URL webhook) — not a per-endpoint secret |
Store the secret in your secret manager or env var — never commit it.
Reference implementations
All four verify the raw request body:
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endFramework-specific wiring
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})Verifying tool calls
When the agent invokes one of your
function tools directly (the tool has an
endpoint), the request carries two ThunderPhone headers alongside
your configured endpoint.headers:
X-ThunderPhone-Call-ID— the numeric id of the live call.X-ThunderPhone-Signature— HMAC-SHA256, keyed with your org-level webhook secret, over the exact request-body bytes.
The same verify() helper works unchanged, with two wrinkles:
GET/DELETEtools have no body. Arguments travel as query parameters, and the signature is computed over the empty byte string — soverify(b"", sig, secret)(Python) orverify(Buffer.alloc(0), sig, secret)(Node). Do not hash the query string.- Orgs without a legacy webhook configured have no org secret. In
that case tool calls carry only
X-ThunderPhone-Call-IDand no signature header. Configure the legacy webhook (PUT /v1/webhook) to get a signing secret, or authenticate tool calls with your own header viaendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...Webhook-mode tool dispatch (tools without an endpoint, delivered
to your org webhook as telephony.tool / web.tool) is an ordinary
signed webhook — the standard recipe above applies. See
Function Tools for both request shapes.
Common pitfalls
Re-serializing with default formatting
Parsing the body and re-dumping it with your JSON library's
defaults (spaces after , / :, insertion-ordered keys) produces
different bytes and breaks the HMAC. Verify the raw body — or if
you must re-serialize, match our canonical form exactly: sorted
keys, compact separators, UTF-8.
Framework auto-parses JSON
Express's express.json() middleware consumes the body stream
and you lose the raw bytes. Use express.raw() on the webhook
route specifically, or buffer the raw body in a pre-middleware.
Same story for NestJS / Koa — check their "raw body" docs.
Timing-unsafe comparison
expected === signature in JS or expected == signature in
Python are timing-variable comparisons. Use crypto.timingSafeEqual
or hmac.compare_digest respectively. The performance difference
is nil.
Wrong secret for tool endpoints
Direct tool-endpoint calls are signed with the org-level webhook
secret (GET /v1/webhook) — not with any per-endpoint secret
from /v1/developer/webhook-endpoints. Reuse the same verify()
function, but make sure you feed it the org secret on tool routes.
Hashing the query string on GET/DELETE tools
For body-less tool methods the signature covers the empty byte string, keeping one universal recipe: HMAC the raw request body, whatever it is. Hashing the URL or query string will never match.
Not returning 401 on mismatch
Returning 200 on failed verification makes the handler a replay target. Always respond non-2xx if verification fails.