Skip to main content
Every POST we send to your server — webhooks, 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. Not request.json(), not anything that re-serializes — the raw 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.)
No need to re-serialize with sorted keys — our signing is over the exact bytes we sent. If you accidentally parse + re-dump the JSON before verifying, the bytes will differ and signatures will mismatch.

Which secret?

SourceSecret
Webhook endpoint (/v1/developer/webhook-endpoints)Per-endpoint secret returned once at create
Legacy single-URL webhookPer-org secret returned on GET /v1/webhook
Tool-integration endpointSame secret as the webhook endpoint that delivered the telephony.incoming / web.incoming event — requests share the signing key
Store the secret in your secret manager or env var — never commit it.

Reference implementations

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 "")

Framework-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}

Common pitfalls

JSON.parse(body) and re-serialize changes the byte sequence (whitespace, key order, number formatting), which breaks the HMAC. Always verify against the raw request body you received before any parsing.
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.
When the agent calls one of your tool endpoints, the signing key is the same secret as the webhook endpoint that delivered telephony.incoming or web.incoming — not a separate tool secret. Reuse the same verify() function.
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.

Tool integrations

Apply the same verification pattern to tool endpoints.