> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thunderphone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Every webhook and tool request from ThunderPhone is signed. Verify once; reuse everywhere.

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.

```python theme={null}
# 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?

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

## Reference implementations

All four verify the raw request body:

<CodeGroup>
  ```python Python theme={null}
  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 theme={null}
  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 theme={null}
  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 theme={null}
  require "openssl"

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

## Framework-specific wiring

<CodeGroup>
  ```python FastAPI theme={null}
  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}
  ```

  ```javascript Express theme={null}
  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);
    },
  );
  ```

  ```python Django theme={null}
  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})
  ```
</CodeGroup>

## Verifying tool calls

When the agent invokes one of your
[function tools](/tools/overview) 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`.

```python theme={null}
@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](/tools/overview) for both request shapes.

## Common pitfalls

<AccordionGroup>
  <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 `verify()`
    function, but make sure you feed it the org secret on tool routes.
  </Accordion>

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