---
title: "Webhookhandtekeningen verifiëren"
description: "Elke webhook- en toolaanvraag die ThunderPhone verstuurt, wordt ondertekend met een tijdstempel. Verifieer deze met het recept of de SDK-helper hier en wijs herhaalde aanvragen ouder dan vijf minuten af."
---

Elk verzoek dat we naar je server sturen (webhookleveringen, de verouderde
webhook met één URL en aanroepen naar tool-endpoints) bevat twee
handtekeningheaders. Beide gebruiken HMAC-SHA256 met hetzelfde geheim.

| Header | Ondertekent | Bescherming tegen replay-aanvallen |
|--------|------------|------------------------------------|
| `X-ThunderPhone-Signature-V2` (aanbevolen) | Tijdstempel, methode, pad, query en body | Verwerp verzoeken die ouder zijn dan 5 minuten |
| `X-ThunderPhone-Signature` | Alleen body | Geen: een onderschept verzoek blijft altijd verifieerbaar |

Verifieer `X-ThunderPhone-Signature-V2` in nieuwe integraties en migreer
bestaande integraties ernaartoe. `X-ThunderPhone-Signature` wordt nog steeds
ongewijzigd met elk verzoek verzonden, zodat verificateurs die hierop zijn
gebaseerd blijven werken. We kondigen een datum aan voordat we stoppen met het
verzenden ervan.

## Handtekening V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` is de Unix-tijd, in seconden, waarop we het verzoek hebben verzonden. Een opnieuw
  geprobeerd verzoek wordt opnieuw ondertekend wanneer het wordt verzonden, met een eigen `t`.
- `v1` is de hexadecimale HMAC-SHA256 van de onderstaande ondertekende tekenreeks, met jouw
  geheim als sleutel.

De waarde is een door komma's gescheiden lijst met `key=value`-paren. Negeer sleutels die je
niet herkent en accepteer het verzoek als een `v1` overeenkomt: we kunnen bij een toekomstige
wijziging een tweede handtekening toevoegen.

### De ondertekende tekenreeks

Vier velden, elk gevolgd door één nieuwe regel (`\n`, byte `0x0A`), behalve het
laatste:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Veld | Waarde |
|-------|-------|
| `t` | De `t`-waarde uit de header |
| `METHOD` | De HTTP-methode in hoofdletters: `POST`, `GET`, `PUT`, `PATCH` of `DELETE` |
| path and query | Het verzoekdoel uit de HTTP-verzoekregel: het pad, gevolgd door `?` en de querytekenreeks als die aanwezig is. Geen schema, host of fragment. Gebruik dit exact zoals het is ontvangen, zonder het te decoderen of parameters te herschikken |
| `body` | De onbewerkte aanvraagbody. Toolaanroepen met `GET` en `DELETE` hebben geen body, dus hun ondertekende tekenreeks eindigt met de nieuwe regel na het pad |

Verifiëren:

1. Lees vóór enige parsing de header, de methode, het onbewerkte pad en de
   query, en de onbewerkte body.
2. Neem `t` en elke `v1` uit de header.
3. Wijs het verzoek af als `t` meer dan **300 seconden** voor of na
   jouw klok ligt.
4. Bereken de hexadecimale HMAC-SHA256 van de ondertekende tekenreeks met jouw geheim.
5. Vergelijk deze in constante tijd met `v1`. Antwoord met `401` als niets overeenkomt.

### Uitgewerkt voorbeeld

Geheim `whsec_example_4c1f2a9b`. We verzenden dit verzoek op
`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"}
```

De ondertekende tekenreeks bestaat uit deze vier regels, zonder nieuwe regel na de body:

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

De HMAC-SHA256 ervan is
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Controleer je implementatie hiermee, of reproduceer deze met `openssl`:

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

Een `GET`-toolaanroep naar `/tools/lookup?customer_id=cust_123` in dezelfde
seconde heeft een lege body. De ondertekende tekenreeks is
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` en de handtekening
is `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Met de SDK's

De [Python- en TypeScript-SDK's](/api-reference/sdks) bevatten een helper
(`thunderphone` 0.2.0 of hoger, `@thunderphone/sdk` 0.2.0 of hoger). Deze
controleert het venster van 5 minuten, vergelijkt in constante tijd en accepteert een lijst
met geheimen zodat je zonder downtime kunt roteren.

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

### Zonder een 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-integratie

Elk voorbeeld leest het onbewerkte pad en de query en de onbewerkte body, en roept vervolgens de SDK-helper aan.

<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) en de ASGI-waarden `raw_path` en `query_string`
(FastAPI) zijn precies het aanvraagdoel zoals ontvangen. Django's
`get_full_path()` en Flask's `request.path` decoderen het pad eerst, wat
voor gewone paden dezelfde tekenreeks oplevert. Als het endpointpad
procentgecodeerde of gereserveerde tekens bevat, zoals `%2F`, `;` of `=`, lees
dan in plaats daarvan het onbewerkte doel dat je server beschikbaar stelt
(bijvoorbeeld `request.META["RAW_URI"]` op Gunicorn).

## Welk geheim?

Hetzelfde geheim ondertekent beide headers.

| Bron | Geheim |
|--------|--------|
| [Webhookeindpunt](/nl/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` per eindpunt (48 hexadecimale tekens), eenmalig teruggegeven bij het aanmaken |
| [Verouderde webhook met één URL](/api-reference/organizations#legacy-single-url-webhook) | `secret` per organisatie, teruggegeven via `GET /v1/webhook` |
| [Aanroep van een tool-eindpunt](/nl/tools/overview) (rechtstreekse aanroep naar je `endpoint.url`) | Het **webhookgeheim op organisatieniveau** (hetzelfde als voor de verouderde webhook met één URL) — geen geheim per eindpunt |

Sla het geheim op in je geheimenbeheerder of omgevingsvariabele — commit het nooit.

## Toolaanroepen verifiëren

Wanneer de agent een van je
[functietools](/nl/tools/overview) rechtstreeks aanroept (de tool heeft een
`endpoint`), bevat het verzoek deze ThunderPhone-headers naast je
geconfigureerde `endpoint.headers`:

- `X-ThunderPhone-Call-ID` — de numerieke id van de actieve oproep.
- `X-ThunderPhone-Signature-V2` — de tijdgestempelde handtekening hierboven, ondertekend
  met je **webhookgeheim op organisatieniveau**.
- `X-ThunderPhone-Signature` — de verouderde handtekening die alleen de body omvat, met dezelfde sleutel.

Twee dingen verschillen van webhooks:

1. **`GET`- / `DELETE`-tools hebben geen body.** Argumenten worden als queryparameters
   verzonden. De V2-handtekening omvat deze via het pad en de query,
   en het bodyveld is leeg. Verifieer met de onbewerkte queryreeks: het parsen
   van de parameters en het opbouwen van een nieuwe queryreeks kan de volgorde
   of codering ervan wijzigen. De verouderde header ondertekent de lege byte-reeks.
2. **Organisaties zonder geconfigureerde verouderde webhook hebben geen organisatiegeheim.** In
   dat geval bevatten toolaanroepen alleen `X-ThunderPhone-Call-ID` en geen
   handtekeningheaders. Configureer de verouderde webhook
   (`PUT /v1/webhook`) om een ondertekeningsgeheim te krijgen, of verifieer
   toolaanroepen met je eigen 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 laat een bodyparser `req.body` als een leeg object achter bij een `GET`,
dus geef `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)` door.

Tooldispatch in webhook-**modus** (tools zonder een `endpoint`, geleverd
aan je organisatiewebhook als `telephony.tool` / `web.tool`) is een
gewone ondertekende webhook — de standaardmethode hierboven is van
toepassing. Zie [Functietools](/nl/tools/overview) voor beide
aanvraagvormen.

## Verouderde handtekening: `X-ThunderPhone-Signature`

De oorspronkelijke header is de hexadecimale HMAC-SHA256 van alleen de onbewerkte body, met
hetzelfde geheim. Deze bevat geen tijdstempel, dus iedereen die één
ondertekend verzoek onderschept, kan het opnieuw afspelen. De header wordt nog steeds bij elk verzoek verzonden terwijl
integraties naar V2 migreren.

1. Lees de **onbewerkte** requestbody — exact de bytes die we je hebben gestuurd.
2. Bereken `hmac_sha256(secret, body).hexdigest()`.
3. Vergelijk in **constante tijd** met `X-ThunderPhone-Signature`.

We ondertekenen exact de bytes die we verzenden. Die bytes zijn ook de
**canonieke JSON-serialisatie** van de payload — sleutels alfabetisch gesorteerd,
compacte scheidingstekens (`,` en `:` zonder spaties), UTF-8.
Als je framework alleen geparseerde JSON beschikbaar maakt, serialiseer dan canoniek opnieuw en
bereken daarover de HMAC:

```python
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

Geef de voorkeur aan de onbewerkte body — dat is één stap minder en voorkomt problemen door het opnieuw omzetten van JSON-getallen
in sommige talen. Hetzelfde geldt voor het bodyveld van 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>

## Veelvoorkomende valkuilen

<AccordionGroup>
  <Accordion title="Klokafwijking">
    Het venster van 5 minuten wordt gemeten aan de hand van de klok van je server. Houd die
    gesynchroniseerd met NTP; een klok die meer dan 5 minuten afwijkt, weigert elke
    aanvraag.
  </Accordion>

  <Accordion title="Een proxy herschrijft het pad">
    Het ondertekende pad is het pad dat we hebben aangevraagd: het pad van de URL die je
    in ThunderPhone hebt geconfigureerd. Als een reverse proxy of API-gateway een voorvoegsel verwijdert
    of toevoegt voordat je applicatie de aanvraag ziet, bouw dan
    vóór verificatie het pad dat we hebben verzonden opnieuw op (zet bijvoorbeeld het voorvoegsel terug).
  </Accordion>

  <Accordion title="Opnieuw serialiseren met standaardopmaak">
    Het parsen van de body en deze opnieuw wegschrijven met de
    standaardinstellingen van je JSON-bibliotheek (spaties na `,` / `:`, sleutels in invoegvolgorde) levert
    andere bytes op en breekt de HMAC. Verifieer de onbewerkte body — of als
    je opnieuw moet serialiseren, volg dan exact onze canonieke vorm: gesorteerde
    sleutels, compacte scheidingstekens, UTF-8.
  </Accordion>

  <Accordion title="Framework parseert JSON automatisch">
    De `express.json()`-middleware van Express verbruikt de bodystream
    waardoor je de ruwe bytes verliest. Gebruik specifiek op de webhookroute
    `express.raw()`, of buffer de ruwe body in een pre-middleware.
    Hetzelfde geldt voor NestJS / Koa — raadpleeg hun documentatie over "raw body".
  </Accordion>

  <Accordion title="Vergelijking die niet timingveilig is">
    `expected === signature` in JS of `expected == signature` in
    Python zijn vergelijkingen met variabele uitvoeringstijd. Gebruik respectievelijk `crypto.timingSafeEqual`
    of `hmac.compare_digest`. Het prestatieverschil
    is nihil.
  </Accordion>

  <Accordion title="Onjuist geheim voor tool-endpoints">
    Rechtstreekse aanroepen van tool-endpoints worden ondertekend met het **organisatiebrede webhookgeheim**
    (`GET /v1/webhook`) — niet met een geheim per endpoint
    uit `/v1/developer/webhook-endpoints`. Hergebruik dezelfde helper,
    maar zorg ervoor dat je deze op toolroutes het organisatiegeheim geeft.
  </Accordion>

  <Accordion title="Queryreeksen bij GET/DELETE-tools">
    V2 ondertekent de queryreeks precies zoals deze is verzonden. De verouderde header ondertekent
    de lege body en nooit de queryreeks, dus het hashen van de URL voor
    de verouderde header komt nooit overeen.
  </Accordion>

  <Accordion title="Duplicaten binnen het venster">
    De tijdstempel stopt herhalingen die ouder zijn dan 5 minuten, niet een tweede kopie
    binnen dat venster. Leveringen aan endpoints gebeuren minstens één keer, dus een
    nieuwe poging voor dezelfde gebeurtenis kan ook aankomen; dedupliceer op de
    `event_id` van de envelop.
  </Accordion>

  <Accordion title="Geen 401 teruggeven bij een mismatch">
    Een 200 teruggeven bij mislukte verificatie maakt de handler een doelwit
    voor replayaanvallen. Geef altijd een niet-2xx-respons terug als de verificatie mislukt.
  </Accordion>
</AccordionGroup>

---

## Volgende stappen

<CardGroup cols={2}>
  <Card title="Overzicht van webhooks" icon="bolt" href="/nl/webhooks/overview">
    Bezorgsemantiek, nieuwe pogingen, bron-IP-adressen.
  </Card>
  <Card title="Webhookeindpunten" icon="plug" href="/nl/webhooks/endpoints">
    Beheer meerdere URL's, roteer geheimen.
  </Card>
  <Card title="Functietools" icon="screwdriver-wrench" href="/nl/tools/overview">
    De twee paden voor toolaanroepen en hun requestvormen.
  </Card>
  <Card title="Toolintegraties" icon="wrench" href="/nl/guides/build-tool-integration">
    Bouw van begin tot eind een complete integratie die door tools wordt ondersteund.
  </Card>
</CardGroup>
