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
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" }'
eventsnimmt 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 (etwaworkflows:read), kommen ab dann auch diese Ereignisse an. Abos aus dem Dashboard erhalten mit*alle Ereignisse.- Der Schlüssel braucht
webhooks:manageund den Lese-Scope jeder Familie, die er mit Namen oder<familie>.*abonniert (envelope.*→envelopes:read,document.*→documents:read, …); sonst 403 / 9002 mitdetails.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:
{
"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)
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)
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.statusbzw.revision, oder lesen Sie im Zweifel das Objekt mitGETnach. - Schnell antworten: Erfolg ist jede
2xx-Antwort innerhalb von 10 Sekunden (Verbindungsaufbau höchstens 5 Sekunden). Antworten Sie sofort mit2xxund 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.