---
title: "Verify webhook signatures"
description: "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

```http
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:

```text
<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:

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

```http
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:

```text
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`:

```bash
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](/api-reference/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.

<CodeGroup>
```python 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
)
```

```javascript 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
});
```
</CodeGroup>

### Without an SDK

<CodeGroup>
```python 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)
```

```javascript 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)
    );
  });
}
```
</CodeGroup>

### Framework wiring

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

<CodeGroup>
```python 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}
```

```javascript 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);
  },
);
```

```python 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})
```
</CodeGroup>

`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](/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | Per-endpoint `secret` (48 hex chars) returned once at create |
| [Legacy single-URL webhook](/api-reference/organizations#legacy-single-url-webhook) | Per-org `secret` returned on `GET /v1/webhook` |
| [Tool-endpoint invocation](/tools/overview) (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](/tools/overview) 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`.

```python
@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](/tools/overview) 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:

```python
# 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.

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## Common pitfalls

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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`.
  </Accordion>

  <Accordion title="Not returning 401 on mismatch">
    Returning 200 on failed verification makes the handler a replay
    target. Always respond non-2xx if verification fails.
  </Accordion>
</AccordionGroup>

---

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks overview" icon="bolt" href="/webhooks/overview">
    Delivery semantics, retries, source IPs.
  </Card>
  <Card title="Webhook endpoints" icon="plug" href="/webhooks/endpoints">
    Manage multiple URLs, rotate secrets.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/tools/overview">
    The two tool-invocation paths and their request shapes.
  </Card>
  <Card title="Tool integrations" icon="wrench" href="/guides/build-tool-integration">
    Build a complete tool-backed integration end to end.
  </Card>
</CardGroup>
