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 with a timestamp. Verify it with the recipe or SDK helper here and reject replayed requests older than five minutes.

Every request we send to your server (webhook deliveries, the legacy single-URL webhook and tool-endpoint calls) carries two signature headers. Both are HMAC-SHA256 with the same secret.

HeaderSignsReplay protection
X-ThunderPhone-Signature-V2 (recommended)Timestamp, method, path, query and bodyReject requests more than 5 minutes old
X-ThunderPhone-SignatureBody onlyNone: a captured request verifies forever

Verify X-ThunderPhone-Signature-V2 in new integrations and move existing ones to it. X-ThunderPhone-Signature is still sent on every request, unchanged, so verifiers built on it keep working. We will announce a date before we stop sending it.

Signature V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t is the Unix time, in seconds, when we sent the request. A retried delivery is signed again when it is sent, with its own t.
  • v1 is the hex HMAC-SHA256 of the signed string below, keyed with your secret.

The value is a comma-separated list of key=value pairs. Ignore keys you do not recognize, and accept the request if any v1 matches: we may add a second signature during a future change.

The signed string

Four fields, each followed by one newline (\n, byte 0x0A) except the last:

<t>\n<METHOD>\n<path and query>\n<body>
FieldValue
tThe t value from the header
METHODThe HTTP method in upper case: POST, GET, PUT, PATCH or DELETE
path and queryThe request target from the HTTP request line: the path, then ? and the query string when there is one. No scheme, host or fragment. Use it exactly as it arrived, without decoding it or re-ordering parameters
bodyThe raw request body. GET and DELETE tool calls have no body, so their signed string ends with the newline after the path

To verify:

  1. Before any parsing, read the header, the method, the raw path and query, and the raw body.
  2. Take t and every v1 from the header.
  3. Reject the request if t is more than 300 seconds before or after your clock.
  4. Compute the hex HMAC-SHA256 of the signed string with your secret.
  5. Compare it with v1 in constant time. Respond 401 if nothing matches.

Worked example

Secret whsec_example_4c1f2a9b. We send this request at t = 1767225600 (2026-01-01 00:00:00 UTC):

POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
 
{"customer_id":"cust_123"}

The signed string is these four lines, with no newline after the body:

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

Its HMAC-SHA256 is 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Check your implementation against it, or reproduce it with openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

A GET tool call to /tools/lookup?customer_id=cust_123 at the same second has an empty body. Its signed string is 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n and its signature is 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

With the SDKs

The Python and TypeScript SDKs include a helper (thunderphone 0.2.0 or later, @thunderphone/sdk 0.2.0 or later). It checks the 5-minute window, compares in constant time, and accepts a list of secrets so you can rotate without downtime.

Python
from thunderphone import verify_signature
 
ok = verify_signature(
    header,            # the X-ThunderPhone-Signature-V2 value
    secret=SECRET,     # or [NEW_SECRET, OLD_SECRET] while rotating
    method=method,     # "POST"
    path=path,         # "/tools/lookup?region=us", exactly as received
    body=raw_body,     # bytes, before any JSON parsing
    tolerance=300,     # optional; seconds
)
Node.js
import { verifySignature } from "@thunderphone/sdk";
 
const ok = verifySignature({
  header,               // the X-ThunderPhone-Signature-V2 value
  secret: SECRET,       // or [NEW_SECRET, OLD_SECRET] while rotating
  method,               // "POST"
  path,                 // "/tools/lookup?region=us", exactly as received
  body: rawBody,        // Buffer or string, before any JSON parsing
  toleranceSeconds: 300, // optional
});

Without an SDK

Python
import hashlib
import hmac
import time
 
TOLERANCE_SECONDS = 300
 
 
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
    """path is the path plus ?query exactly as received; body is the raw body."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        key, _, value = item.strip().partition("=")
        if key == "t" and value.isdigit() and len(value) <= 15:
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
Node.js
import crypto from "node:crypto";
 
const TOLERANCE_SECONDS = 300;
 
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
  let timestamp;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [key, value = ""] = item.trim().split("=", 2);
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === undefined) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
      .update(body)
      .digest("hex"),
  );
  return signatures.some((sig) => {
    const candidate = Buffer.from(sig);
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}

Framework wiring

Each example reads the raw path and query and the raw body, then calls the SDK helper.

FastAPI
import json
 
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
 
app = FastAPI()
 
 
def raw_path_and_query(request: Request) -> str:
    path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
    query = request.scope.get("query_string", b"").decode("latin-1")
    return f"{path}?{query}" if query else path
 
 
@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
Express
import express from "express";
import { verifySignature } from "@thunderphone/sdk";
 
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 ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl, // path and query as received
      body: req.body,
    });
    if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
 
 
@csrf_exempt
@require_POST
def hook(request):
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=request.get_full_path(),
        body=request.body,  # raw bytes
    ):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(request.body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})

req.originalUrl (Express) and the ASGI raw_path and query_string (FastAPI) are the target exactly as received. Django's get_full_path() and Flask's request.path decode the path first, which gives the same string for ordinary paths. If your endpoint path contains percent-encoded or reserved characters such as %2F, ; or =, read the raw target your server provides instead (for example request.META["RAW_URI"] on Gunicorn).

Which secret?

The same secret signs both headers.

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.

Verifying tool calls

When the agent invokes one of your function tools directly (the tool has an endpoint), the request carries these ThunderPhone headers alongside your configured endpoint.headers:

  • X-ThunderPhone-Call-ID — the numeric id of the live call.
  • X-ThunderPhone-Signature-V2 — the timestamped signature above, keyed with your org-level webhook secret.
  • X-ThunderPhone-Signature — the legacy body-only signature, same key.

Two things differ from webhooks:

  1. GET / DELETE tools have no body. Arguments travel as query parameters. The V2 signature covers them through the path and query, and the body field is empty. Verify with the raw query string: parsing the parameters and building a new query string can change their order or encoding. The legacy header signs the empty byte 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 headers. Configure the legacy webhook (PUT /v1/webhook) to get a signing secret, or authenticate tool calls with your own header via endpoint.headers.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=ORG_WEBHOOK_SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    args = json.loads(body) if body else dict(request.query_params)
    ...

In Express, a body parser leaves req.body as an empty object on a GET, so pass Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

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.

Legacy signature: X-ThunderPhone-Signature

The original header is the hex HMAC-SHA256 of the raw body alone, with the same secret. It carries no timestamp, so anyone who captures one signed request can replay it. It is still sent on every request while integrations move to V2.

  1. Read the raw request body — the exact bytes we sent you.
  2. Compute hmac_sha256(secret, body).hexdigest().
  3. Compare in constant time against X-ThunderPhone-Signature.

We sign exactly the bytes we transmit. Those bytes are also the canonical JSON serialization of the payload — keys sorted alphabetically, compact separators (, and : with no spaces), UTF-8. If 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. The same holds for the body field of V2.

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

Common pitfalls

Clock skew

The 5-minute window is measured against your server's clock. Keep it synchronized with NTP; a clock more than 5 minutes off rejects every request.

A proxy rewrites the path

The signed path is the one we requested: the path of the URL you configured in ThunderPhone. If a reverse proxy or API gateway strips or adds a prefix before your application sees the request, rebuild the path we sent (for example, put the prefix back) before verifying.

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 helper, but make sure you feed it the org secret on tool routes.

Query strings on GET/DELETE tools

V2 signs the query string exactly as sent. The legacy header signs the empty body and never the query string, so hashing the URL for the legacy header will never match.

Duplicates inside the window

The timestamp stops replays older than 5 minutes, not a second copy inside that window. Endpoint deliveries are at-least-once, so a retry of the same event can also arrive; deduplicate on the envelope's event_id.

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