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.)
, 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.
Which secret?
Store the secret in your secret manager or env var — never commit it.
Reference implementations
All four verify the raw request body:Framework-specific wiring
Verifying tool calls
When the agent invokes one of your function tools directly (the tool has anendpoint), 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.
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.
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
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
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
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
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
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
Not returning 401 on mismatch
Returning 200 on failed verification makes the handler a replay
target. Always respond non-2xx if verification fails.
Next steps
Webhooks overview
Delivery semantics, retries, source IPs.
Webhook endpoints
Manage multiple URLs, rotate secrets.
Function Tools
The two tool-invocation paths and their request shapes.
Tool integrations
Build a complete tool-backed integration end to end.