# Webhooks empfangen und prüfen

Webhooks melden Ihrem System, was passiert ist – ein Vorgang wurde unterschrieben, ein Dokument ist geprüft, ein Zertifikat läuft ab. Dieser Guide zeigt, wie Sie ein Abo anlegen, jede Zustellung prüfen und Ereignisse genau einmal verarbeiten. Die vollständige Liste der Ereignisse mit Body und Beispiel steht in der API-Referenz im Abschnitt „Webhooks“ und unter `GET /v1/event-types`.

## 1. Abo anlegen

```sh
curl -X POST "$BASE_URL/v1/webhook-subscriptions" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: crm-abo-1' \
  -d '{ "url": "https://example.com/hooks/signatur", "events": ["envelope.completed", "recipient.*"], "description": "CRM" }'
```

- `events` nimmt Namen (`envelope.completed`), Familien (`recipient.*`) oder `*`. `*` heißt: alle Ereignisse, die der Schlüssel des Abos lesen darf. Ereignisse anderer Familien kommen nicht an; bekommt der Schlüssel später einen weiteren Lese-Scope (etwa `workflows:read`), kommen ab dann auch diese Ereignisse an. Abos aus dem Dashboard erhalten mit `*` alle Ereignisse.
- Der Schlüssel braucht `webhooks:manage` und den Lese-Scope jeder Familie, die er mit Namen oder `<familie>.*` abonniert (`envelope.*` → `envelopes:read`, `document.*` → `documents:read`, …); sonst 403 / 9002 mit `details.missingScopes`. Für `*` reicht ein Lese-Scope irgendeiner Familie.
- Ändert ein anderer Schlüssel die URL oder die Ereignisse eines Abos mit `*`, braucht er die Lese-Scopes aller Familien.
- Die URL muss `https://` auf Port 443 oder 8443 sein und auf eine öffentliche Adresse zeigen.
- Ein Test-Schlüssel legt ein Abo für Test-Ereignisse an, ein Live-Schlüssel eines für Live-Ereignisse. Bis zu 25 Abos je Modus.

Die Antwort (201) enthält `secret` (`whsec_…`) **nur dieses eine Mal**. Haben Sie es verloren, erzeugen Sie mit `POST /v1/webhook-subscriptions/{id}/rotate-secret` ein neues.

Zum Ausprobieren schickt `POST /v1/webhook-subscriptions/{id}/test` eine signierte Test-Zustellung (`"test": true` im Body).

## 2. Was ankommt

Jede Zustellung ist ein `POST` mit JSON-Body:

```json
{
  "id": "0192f1c2-7a10-7c3e-9d2b-3f0a1b2c3d4e",
  "object": "event",
  "type": "envelope.completed",
  "event": "envelope.completed",
  "apiVersion": "1",
  "createdAt": "2026-11-03T09:15:02.113Z",
  "orgId": "0190a000-0000-7000-8000-000000000001",
  "mode": "TEST",
  "subscriptionId": "0192e0aa-0000-7000-8000-00000000000a",
  "context": { "envelopeId": "0192f1b0-0000-7000-8000-000000000101", "externalId": "crm-4711" },
  "data": { "object": "envelope", "id": "0192f1b0-0000-7000-8000-000000000101", "status": "COMPLETED" }
}
```

`data` ist das Objekt der passenden `GET`-Route zum Zeitpunkt des Ereignisses (hier gekürzt), `context` nennt die Bezüge und Ihre `externalId`, `previous` (falls vorhanden) die geänderten Felder mit ihrem alten Wert.

Dazu kommen zwei Sätze von Signatur-Headern über denselben Roh-Body. Es genügt, **einen** davon zu prüfen:

| Header | Inhalt |
| --- | --- |
| `webhook-id` | Ereignis-ID (gleich `id`), bei jedem Versuch gleich |
| `webhook-timestamp` | Unix-Sekunden dieses Versuchs |
| `webhook-signature` | `v1,<Base64 HMAC-SHA256>` über `<webhook-id>.<webhook-timestamp>.<Roh-Body>`; Schlüssel: die Base64-dekodierten Bytes nach `whsec_` |
| `X-QGW-Signature` | `t=<Unix-Sekunden>,v1=<Hex HMAC-SHA256>` über `<t>.<Roh-Body>`; Schlüssel: der ganze Text `whsec_…` |
| `X-QGW-Delivery-Id` | ID dieser Zustellung (je Abo, bei jedem Versuch gleich) |
| `X-QGW-Event` | Name des Ereignisses |

Während einer Secret-Rotation tragen beide Signatur-Header je einen Wert pro gültigem Secret; akzeptieren Sie die Anfrage, wenn einer passt.

## 3. Signatur prüfen

Prüfen Sie immer gegen den **Roh-Body** (die empfangenen Bytes, bevor JSON geparst wird), lehnen Sie Zeitstempel ab, die älter als fünf Minuten sind, und vergleichen Sie in konstanter Zeit.

### Node.js (Standard Webhooks)

```ts
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.WEBHOOK_SECRET!); // whsec_…
const app = express();

app.post('/hooks/signatur', express.raw({ type: 'application/json' }), async (req, res) => {
  let event: { id: string; type: string };
  try {
    event = wh.verify(req.body, {
      'webhook-id': req.header('webhook-id') ?? '',
      'webhook-timestamp': req.header('webhook-timestamp') ?? '',
      'webhook-signature': req.header('webhook-signature') ?? '',
    }) as { id: string; type: string };
  } catch {
    return res.status(400).end();
  }
  if (await alreadyProcessed(event.id)) return res.status(204).end();
  await enqueue(event); // Arbeit im Hintergrund
  res.status(204).end();
});
```

### Python (`X-QGW-Signature`, nur Standardbibliothek)

```python
import hashlib, hmac, time

def verify_qgw_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    timestamp, signatures = None, []
    for part in (header or "").split(","):
        key, _, value = part.partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

In Flask liefert `request.get_data()` den Roh-Body, in FastAPI `await request.body()`. Für den Header-Satz `webhook-*` gibt es die offiziellen Standard-Webhooks-Bibliotheken auch für Python (`pip install standardwebhooks`), Go, Java und weitere Sprachen.

## 4. Genau einmal verarbeiten

- **Mindestens einmal:** Dasselbe Ereignis kann mehrfach ankommen (etwa wenn Ihre Antwort verloren ging). Speichern Sie `webhook-id` (= `id`) und ignorieren Sie Wiederholungen.
- **Reihenfolge:** Zustellungen kommen nicht garantiert in der Reihenfolge der Ereignisse. Entscheiden Sie anhand von `createdAt`, `data.status` bzw. `revision`, oder lesen Sie im Zweifel das Objekt mit `GET` nach.
- **Schnell antworten:** Erfolg ist jede `2xx`-Antwort innerhalb von 10 Sekunden (Verbindungsaufbau höchstens 5 Sekunden). Antworten Sie sofort mit `2xx` und erledigen Sie die eigentliche Arbeit im Hintergrund.
- **Unbekannte Ereignisse:** Es kommen neue Ereignisnamen hinzu. Antworten Sie auch auf unbekannte mit `2xx`.

## 5. Wiederholungen und Fehler

Jede andere Antwort, ein Timeout oder ein Netzwerkfehler führt zu weiteren Versuchen (bei Abos nach etwa 1, 5, 15 und 60 Minuten, 3 und 6 Stunden, danach alle 12 Stunden, bis 72 Stunden nach der Anlage; jeder Versuch zu einem zufälligen Zeitpunkt in der zweiten Hälfte seines Schritts; danach, oder wenn eine zurückgehaltene Zustellung die 72 Stunden erreicht, gilt sie als fehlgeschlagen). Weiterleitungen (`3xx`) werden nicht verfolgt und zählen als Fehler. `410 Gone` beendet die Zustellung und schaltet das Abo ab.

Das Zustellprotokoll lesen Sie mit `GET /v1/webhook-subscriptions/{id}/deliveries` und `GET /v1/webhook-deliveries/{id}`; `POST /v1/webhook-deliveries/{id}/retry` stellt eine Zustellung erneut zu (gleiche `webhook-id`). Was Ihr Endpunkt verpasst hat, finden Sie für 30 Tage unter `GET /v1/events`.

Nach 10 endgültig gescheiterten Zustellungen in Folge über mindestens 24 Stunden wird das Abo pausiert; `PATCH /v1/webhook-subscriptions/{id}` mit `{ "state": "ACTIVE" }` setzt es fort.
