---
title: "Webhook-Signaturen überprüfen"
description: "Jede Webhook- und Tool-Anfrage, die ThunderPhone sendet, wird mit einem Zeitstempel signiert. Überprüfen Sie sie mit dem Rezept oder SDK-Helfer hier und lehnen Sie wiederholte Anfragen ab, die älter als fünf Minuten sind."
---

Jede Anfrage, die wir an Ihren Server senden (Webhook-Zustellungen, der ältere
Single-URL-Webhook und Aufrufe von Tool-Endpunkten), enthält zwei
Signatur-Header. Beide verwenden HMAC-SHA256 mit demselben Secret.

| Header | Signiert | Schutz vor Replay-Angriffen |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (empfohlen) | Zeitstempel, Methode, Pfad, Abfrage und Body | Anfragen ablehnen, die älter als 5 Minuten sind |
| `X-ThunderPhone-Signature` | Nur den Body | Keiner: Eine abgefangene Anfrage wird dauerhaft verifiziert |

Verifizieren Sie `X-ThunderPhone-Signature-V2` in neuen Integrationen und stellen
Sie bestehende Integrationen darauf um. `X-ThunderPhone-Signature` wird weiterhin
unverändert mit jeder Anfrage gesendet, sodass darauf basierende Verifizierer
weiter funktionieren. Wir kündigen ein Datum an, bevor wir den Versand einstellen.

## Signatur V2

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

- `t` ist die Unix-Zeit in Sekunden, zu der wir die Anfrage gesendet haben. Eine erneut zugestellte Anfrage wird beim Versand erneut signiert und erhält ein eigenes `t`.
- `v1` ist der hexadezimale HMAC-SHA256-Hash der untenstehenden signierten Zeichenfolge, der mit Ihrem Secret erzeugt wurde.

Der Wert ist eine durch Kommas getrennte Liste von `key=value`-Paaren. Ignorieren Sie Schlüssel, die Sie nicht erkennen, und akzeptieren Sie die Anfrage, wenn ein beliebiges `v1` übereinstimmt: Bei einer zukünftigen Änderung können wir eine zweite Signatur hinzufügen.

### Die signierte Zeichenfolge

Vier Felder, jeweils gefolgt von einem Zeilenumbruch (`\n`, Byte `0x0A`), außer dem letzten:

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

| Feld | Wert |
|-------|-------|
| `t` | Der `t`-Wert aus dem Header |
| `METHOD` | Die HTTP-Methode in Großbuchstaben: `POST`, `GET`, `PUT`, `PATCH` oder `DELETE` |
| path and query | Das Anfrageziel aus der HTTP-Anfragezeile: der Pfad, gefolgt von `?` und der Abfragezeichenfolge, sofern vorhanden. Kein Schema, Host oder Fragment. Verwenden Sie es exakt so, wie es eingegangen ist, ohne es zu dekodieren oder Parameter neu anzuordnen |
| `body` | Der unverarbeitete Anfrage-Body. `GET`- und `DELETE`-Tool-Aufrufe haben keinen Body, daher endet ihre signierte Zeichenfolge mit dem Zeilenumbruch nach dem Pfad |

So verifizieren Sie die Signatur:

1. Lesen Sie vor jeder Verarbeitung den Header, die Methode, den unverarbeiteten Pfad und die Abfrage sowie den unverarbeiteten Body.
2. Entnehmen Sie `t` und jedes `v1` aus dem Header.
3. Lehnen Sie die Anfrage ab, wenn `t` mehr als **300 Sekunden** vor oder nach Ihrer Uhrzeit liegt.
4. Berechnen Sie mit Ihrem Secret den hexadezimalen HMAC-SHA256-Hash der signierten Zeichenfolge.
5. Vergleichen Sie ihn in konstanter Zeit mit `v1`. Antworten Sie mit `401`, wenn keine Übereinstimmung vorliegt.

### Durchgerechnetes Beispiel

Secret `whsec_example_4c1f2a9b`. Wir senden diese Anfrage um
`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"}
```

Die signierte Zeichenfolge besteht aus diesen vier Zeilen, ohne Zeilenumbruch nach dem Body:

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

Ihr HMAC-SHA256-Hash lautet
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Prüfen Sie Ihre Implementierung dagegen oder reproduzieren Sie ihn mit `openssl`:

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

Ein `GET`-Tool-Aufruf an `/tools/lookup?customer_id=cust_123` in derselben
Sekunde hat einen leeren Body. Seine signierte Zeichenfolge lautet
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` und seine Signatur
lautet `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Mit den SDKs

Die [Python- und TypeScript-SDKs](/api-reference/sdks) enthalten eine Hilfsfunktion
(`thunderphone` 0.2.0 oder höher, `@thunderphone/sdk` 0.2.0 oder höher). Sie
prüft das 5-Minuten-Fenster, vergleicht in konstanter Zeit und akzeptiert eine Liste
von Secrets, damit Sie diese ohne Ausfallzeit rotieren können.

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

### Ohne 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-Anbindung

Jedes Beispiel liest den rohen Pfad und die Query sowie den rohen Body und ruft dann
den SDK-Helper auf.

<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) sowie ASGI-`raw_path` und `query_string`
(FastAPI) entsprechen exakt dem empfangenen Ziel. Djangos
`get_full_path()` und Flasks `request.path` dekodieren den Pfad zuerst, was
bei gewöhnlichen Pfaden dieselbe Zeichenfolge ergibt. Wenn Ihr Endpunktpfad
prozentkodierte oder reservierte Zeichen wie `%2F`, `;` oder `=` enthält, lesen Sie
stattdessen das von Ihrem Server bereitgestellte rohe Ziel (zum Beispiel `request.META["RAW_URI"]` unter
Gunicorn).

## Welches Secret?

Dasselbe Secret signiert beide Header.

| Quelle | Secret |
|--------|--------|
| [Webhook-Endpunkt](/de/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | Pro Endpunkt ein `secret` (48 Hex-Zeichen), das bei der Erstellung einmalig zurückgegeben wird |
| [Legacy-Webhook mit einzelner URL](/api-reference/organizations#legacy-single-url-webhook) | Pro Organisation ein `secret`, das bei `GET /v1/webhook` zurückgegeben wird |
| [Aufruf eines Tool-Endpunkts](/de/tools/overview) (direkter Aufruf Ihrer `endpoint.url`) | Das **Webhook-Secret auf Organisationsebene** (dasselbe wie beim Legacy-Webhook mit einzelner URL) — kein Secret pro Endpunkt |

Speichern Sie das Secret in Ihrem Secret-Manager oder einer Umgebungsvariable — committen Sie es niemals.

## Tool-Aufrufe verifizieren

Wenn der Sprachagent eines Ihrer
[Funktionstools](/de/tools/overview) direkt aufruft (das Tool verfügt über einen
`endpoint`), enthält die Anfrage neben Ihren konfigurierten
`endpoint.headers` diese ThunderPhone-Header:

- `X-ThunderPhone-Call-ID` — die numerische ID des laufenden Anrufs.
- `X-ThunderPhone-Signature-V2` — die oben beschriebene zeitgestempelte Signatur,
  die mit Ihrem **Webhook-Secret auf Organisationsebene** erstellt wird.
- `X-ThunderPhone-Signature` — die ältere Signatur nur für den Body, mit demselben Schlüssel.

Zwei Aspekte unterscheiden sich von Webhooks:

1. **`GET`- / `DELETE`-Tools haben keinen Body.** Argumente werden als
   Abfrageparameter übertragen. Die V2-Signatur deckt sie über Pfad und Query ab,
   und das Body-Feld ist leer. Prüfen Sie mit dem unverarbeiteten Query-String:
   Das Parsen der Parameter und Erstellen eines neuen Query-Strings kann deren
   Reihenfolge oder Codierung ändern. Der ältere Header signiert die leere Bytefolge.
2. **Organisationen ohne konfigurierten älteren Webhook haben kein Organisations-Secret.**
   In diesem Fall enthalten Tool-Aufrufe nur `X-ThunderPhone-Call-ID` und keine
   Signatur-Header. Konfigurieren Sie den älteren Webhook
   (`PUT /v1/webhook`), um ein Signatur-Secret zu erhalten, oder authentifizieren Sie
   Tool-Aufrufe mit Ihrem eigenen Header über `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 hinterlässt ein Body-Parser bei einer `GET`-Anfrage `req.body` als leeres Objekt.
Übergeben Sie daher `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Der Tool-Versand im Webhook-**Modus** (Tools ohne `endpoint`, die als
`telephony.tool` / `web.tool` an Ihren Organisations-Webhook zugestellt werden)
ist ein gewöhnlicher signierter Webhook — die obige Standardanleitung gilt.
Unter [Function Tools](/de/tools/overview) finden Sie beide Anfrageformen.

## Legacy-Signatur: `X-ThunderPhone-Signature`

Der ursprüngliche Header ist der hexadezimale HMAC-SHA256 des reinen Rohtexts des Bodys, mit
demselben Secret. Er enthält keinen Zeitstempel, sodass jeder, der eine
signierte Anfrage abfängt, sie erneut senden kann. Während Integrationen zu V2 wechseln,
wird er weiterhin mit jeder Anfrage gesendet.

1. Lesen Sie den **rohen** Anfragetext — die exakten Bytes, die wir Ihnen gesendet haben.
2. Berechnen Sie `hmac_sha256(secret, body).hexdigest()`.
3. Vergleichen Sie in **konstanter Zeit** mit `X-ThunderPhone-Signature`.

Wir signieren exakt die Bytes, die wir übertragen. Diese Bytes sind auch die
**kanonische JSON-Serialisierung** der Nutzdaten — Schlüssel alphabetisch sortiert,
kompakte Trennzeichen (`,` und `:` ohne Leerzeichen), UTF-8.
Wenn Ihr Framework nur geparstes JSON bereitstellt, serialisieren Sie es kanonisch erneut und
berechnen Sie den HMAC dafür:

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

Bevorzugen Sie den Rohtext des Bodys — das ist ein Schritt weniger und unempfindlich gegenüber Besonderheiten beim JSON-Zahlen-Roundtrip
in einigen Sprachen. Dasselbe gilt für das Body-Feld von 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>

## Häufige Fallstricke

<AccordionGroup>
  <Accordion title="Zeitabweichung">
    Das 5-Minuten-Fenster wird anhand der Uhr Ihres Servers gemessen. Halten Sie sie
    mit NTP synchronisiert; eine Uhr, die mehr als 5 Minuten abweicht, lehnt jede
    Anfrage ab.
  </Accordion>

  <Accordion title="Ein Proxy schreibt den Pfad um">
    Der signierte Pfad ist der von uns angeforderte: der Pfad der URL, die Sie
    in ThunderPhone konfiguriert haben. Wenn ein Reverse Proxy oder API-Gateway
    ein Präfix entfernt oder hinzufügt, bevor Ihre Anwendung die Anfrage sieht,
    stellen Sie vor der Verifizierung den von uns gesendeten Pfad wieder her
    (fügen Sie beispielsweise das Präfix wieder hinzu).
  </Accordion>

  <Accordion title="Erneute Serialisierung mit Standardformatierung">
    Das Parsen des Bodys und erneute Ausgeben mit den Standardeinstellungen Ihrer
    JSON-Bibliothek (Leerzeichen nach `,` / `:`, Schlüssel in Einfügereihenfolge) erzeugt
    andere Bytes und beschädigt den HMAC. Verifizieren Sie den Roh-Body — oder gleichen
    Sie, falls Sie erneut serialisieren müssen, exakt unsere kanonische Form ab: sortierte
    Schlüssel, kompakte Trennzeichen, UTF-8.
  </Accordion>

  <Accordion title="Framework parst JSON automatisch">
    Die `express.json()`-Middleware von Express verarbeitet den Body-Stream
    und die rohen Bytes gehen verloren. Verwenden Sie gezielt `express.raw()` für die
    Webhook-Route oder puffern Sie den rohen Body in einer vorgeschalteten Middleware.
    Dasselbe gilt für NestJS / Koa — prüfen Sie deren Dokumentation zu „raw body“.
  </Accordion>

  <Accordion title="Nicht zeitkonstanter Vergleich">
    `expected === signature` in JS oder `expected == signature` in
    Python sind zeitvariable Vergleiche. Verwenden Sie jeweils `crypto.timingSafeEqual`
    oder `hmac.compare_digest`. Der Leistungsunterschied
    ist null.
  </Accordion>

  <Accordion title="Falsches Geheimnis für Tool-Endpunkte">
    Direkte Aufrufe von Tool-Endpunkten werden mit dem **Webhook-Geheimnis auf
    Organisationsebene** (`GET /v1/webhook`) signiert — nicht mit einem Geheimnis
    pro Endpunkt aus `/v1/developer/webhook-endpoints`. Verwenden Sie denselben Helfer
    erneut, stellen Sie jedoch sicher, dass Sie ihm auf Tool-Routen das Organisationsgeheimnis übergeben.
  </Accordion>

  <Accordion title="Abfragezeichenfolgen bei GET/DELETE-Tools">
    V2 signiert die Abfragezeichenfolge genau wie gesendet. Der Legacy-Header signiert
    den leeren Body und niemals die Abfragezeichenfolge. Daher wird das Hashing der URL für
    den Legacy-Header niemals übereinstimmen.
  </Accordion>

  <Accordion title="Duplikate innerhalb des Zeitfensters">
    Der Zeitstempel verhindert Wiederholungen, die älter als 5 Minuten sind, nicht jedoch eine zweite
    Kopie innerhalb dieses Fensters. Endpunkt-Zustellungen erfolgen mindestens einmal, daher kann auch eine
    Wiederholung desselben Ereignisses eintreffen; deduplizieren Sie anhand der
    `event_id` des Envelopes.
  </Accordion>

  <Accordion title="Bei Nichtübereinstimmung kein 401 zurückgeben">
    Wenn Sie bei fehlgeschlagener Verifizierung 200 zurückgeben, wird der Handler zu einem
    Ziel für Replay-Angriffe. Antworten Sie immer mit einem Nicht-2xx-Status, wenn die Verifizierung fehlschlägt.
  </Accordion>
</AccordionGroup>

---

## Nächste Schritte

<CardGroup cols={2}>
  <Card title="Webhook-Übersicht" icon="bolt" href="/de/webhooks/overview">
    Zustellsemantik, Wiederholungsversuche, Quell-IP-Adressen.
  </Card>
  <Card title="Webhook-Endpunkte" icon="plug" href="/de/webhooks/endpoints">
    Mehrere URLs verwalten, Secrets rotieren.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/de/tools/overview">
    Die zwei Pfade für Tool-Aufrufe und ihre Anfrageformate.
  </Card>
  <Card title="Tool-Integrationen" icon="wrench" href="/de/guides/build-tool-integration">
    Erstellen Sie eine vollständige Tool-gestützte Integration von Anfang bis Ende.
  </Card>
</CardGroup>
