Skip to main content
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

  1. Read the raw request body — the exact bytes we POSTed to you.
  2. Compute hmac_sha256(secret, body).hexdigest().
  3. 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.
Prefer the raw body — it’s one less step and immune to JSON number round-tripping quirks in some languages.

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 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:
  1. GET / DELETE tools have no body. Arguments travel as query parameters, and the signature is computed over the empty byte string — so verify(b"", sig, secret) (Python) or verify(Buffer.alloc(0), sig, secret) (Node). Do not hash the query string.
  2. Orgs without a legacy webhook configured have no org secret. In that case tool calls carry only X-ThunderPhone-Call-ID and no signature header. Configure the legacy webhook (PUT /v1/webhook) to get a signing secret, or authenticate tool calls with your own header via endpoint.headers.
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

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.
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.
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.
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.
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.
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.