Open in
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.
| Header | Signs | Replay protection |
|---|---|---|
X-ThunderPhone-Signature-V2 (recommended) | Timestamp, method, path, query and body | Reject requests more than 5 minutes old |
X-ThunderPhone-Signature | Body only | None: 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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etis the Unix time, in seconds, when we sent the request. A retried delivery is signed again when it is sent, with its ownt.v1is 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>| Field | Value |
|---|---|
t | The t value from the header |
METHOD | The HTTP method in upper case: POST, GET, PUT, PATCH or DELETE |
| path and query | The 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 |
body | The raw request body. GET and DELETE tool calls have no body, so their signed string ends with the newline after the path |
To verify:
- Before any parsing, read the header, the method, the raw path and query, and the raw body.
- Take
tand everyv1from the header. - Reject the request if
tis more than 300 seconds before or after your clock. - Compute the hex HMAC-SHA256 of the signed string with your secret.
- Compare it with
v1in constant time. Respond401if 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.
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
)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
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)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.
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}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);
},
);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.
| Source | Secret |
|---|---|
Webhook endpoint (/v1/developer/webhook-endpoints) | Per-endpoint secret (48 hex chars) returned once at create |
| Legacy single-URL webhook | Per-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:
GET/DELETEtools 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.- Orgs without a legacy webhook configured have no org secret. In
that case tool calls carry only
X-ThunderPhone-Call-IDand no signature headers. Configure the legacy webhook (PUT /v1/webhook) to get a signing secret, or authenticate tool calls with your own header viaendpoint.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.
- Read the raw request body — the exact bytes we sent you.
- Compute
hmac_sha256(secret, body).hexdigest(). - 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.
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 "")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),
);
}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))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endCommon 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.