ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Operations

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

  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.

# 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?

SourceSecret
Webhook endpoint (/v1/developer/webhook-endpoints)Per-endpoint secret (48 hex chars) returned once at create
Legacy single-URL webhookPer-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:

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Framework-specific wiring

FastAPI
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}
Express
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);
  },
);
Django
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:

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


Next steps