Webhooks erklärt: Signierte Payloads und zuverlässige Retries

Backend2026-11-05TryQuickToolBox

Deine Integration funktioniert im Staging: Der Zahlungsanbieter löst ein Event aus, dein Endpunkt empfängt es, die Bestellung wechselt auf paid. In der Produktion zahlt ein Kunde dann doppelt, eine Zustellung verschwindet während eines Deploys, und jemand spielt eine abgefangene Anfrage erneut ein, um eine Bestellung als erstattet zu markieren. Alle drei Fehler haben dieselbe Ursache: Der Webhook wurde als einfacher HTTP-POST behandelt statt als sicherheitskritischer, At-least-once-Zustellungskanal.

Dieser Leitfaden behandelt die beiden Hälften einer korrekten Webhook-Implementierung – die Verifikation der Authentizität einer Payload und die Sicherstellung der Zustellung trotz Retries – mit Code, den du an jeden Stack anpassen kannst.

Was ein Webhook wirklich ist

Ein Webhook ist eine Server-zu-Server-HTTP-Anfrage, die ein System an ein anderes sendet, wenn ein Event eintritt. Er kehrt das übliche Polling-Modell um: Statt dass deine App jede Minute fragt „gibt es etwas Neues?“, pusht der Produzent das Event an eine von dir registrierte URL.

Aus diesem Design folgen zwei Eigenschaften, die beide wichtig sind:

Alles Folgende ist eine Konsequenz aus diesen beiden Tatsachen.

Payloads mit HMAC signieren

Der Standardansatz ist ein gemeinsames Secret plus ein HMAC über den rohen Request-Body. Der Sender berechnet eine Signatur und legt sie in einen Header; du berechnest sie neu und vergleichst. Wenn sie übereinstimmen, stammt die Payload von jemandem, der das Secret kennt, und wurde unterwegs nicht verändert.

Drei Details unterscheiden eine funktionierende Implementierung von einer fehlerhaften:

  1. Signiere die rohen Bytes. Parse JSON erst nach der Verifikation. Erneutes Serialisieren des Bodys ändert Whitespace und Schlüsselreihenfolge, und die Signatur wird nie übereinstimmen.
  2. Verwende einen konstantzeitigen Vergleich. Ein naives == verrät Timing-Informationen. Nutze den eingebauten sicheren Vergleich der Sprache.
  3. Füge einen Zeitstempel in den signierten String ein. Wenn du timestamp + "." + body signierst, kannst du alte Anfragen ablehnen, was Replay-Angriffe blockiert.

Signatur in Node.js verifizieren

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recompute over the exact bytes received
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Constant-time compare
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express erfordert dies express.raw({ type: 'application/json' }) auf der Webhook-Route, damit der Body als Buffer ankommt. Wenn zuerst eine globale express.json()-Middleware läuft, ist der rohe Body weg und die Verifikation schlägt fehl – ein sehr häufiger Bug.

Verifizierung in Python

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Gib bei fehlgeschlagener Prüfung immer 401 zurück und logge die Request-ID – aber logge niemals das Secret oder die vollständige Signatur.

Retries entwerfen, die wirklich funktionieren

Retries sind nur nützlich, wenn sich dein Endpunkt vorhersehbar verhält. Der Vertrag ist einfach: Gib schnell 2xx zurück, sobald du das Event dauerhaft akzeptiert hast, und lass alles andere erneut versuchen.

Deine AntwortVerhalten des SendersWas du stattdessen tun solltest
200 / 204Als zugestellt markierenKorrekt – bestätige nach dem Einreihen in die Queue
400 / 401Normalerweise kein RetrySignatur- oder Payload-Verarbeitung korrigieren
429 / 503Retry mit BackoffZurückgeben, wenn überlastet, nicht als generischer 500
500 / TimeoutRetryLangsame Arbeit in eine Queue verschieben

Zwei Regeln halten das gesund:

Handler idempotent machen

Da die Zustellung mindestens einmal erfolgt, kann dieselbe Event-ID zweimal ankommen – einmal nach einem Timeout, bei dem dein Handler tatsächlich erfolgreich war. Speichere verarbeitete Event-IDs und überspringe Duplikate:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.

Mach die Geschäftsoperation selbst idempotent, wo möglich – setze den Bestellstatus auf paid, anstatt einen Zähler zu erhöhen, und verwende die Event-ID als Idempotenzschlüssel beim Aufruf nachgelagerter Dienste.

Eine Checkliste vor dem Go-live

  1. Verifiziere den HMAC über rohe Bytes mit einem konstantzeitigen Vergleich.
  2. Lehne Zeitstempel ab, die älter als ein paar Minuten sind, um Replays zu stoppen.
  3. Persistiere das Event und gib dann innerhalb von ein bis zwei Sekunden 2xx zurück.
  4. Dedupliziere anhand der Event-ID, bevor du irgendeine Arbeit erledigst.
  5. Logge Event-ID, Typ und Ergebnis – niemals das Secret.
  6. Biete eine Möglichkeit, Events manuell aus deinem Dashboard oder Admin-Tool erneut abzuspielen.
  7. Teste mit absichtlich fehlerhaften Signaturen, doppelten Zustellungen und langsamen Handlern.

Wenn du eine fehlschlagende Integration debuggst, ist der rohe Request-Body das, was du am meisten anstarren wirst. Wenn du ihn in einen JSON-Formatter einfügst, werden verschachtelte Event-Objekte lesbar, ohne die Bytes zu verändern, die du bereits erfasst hast.

FAQ

Warum schlägt meine Signaturprüfung fehl, obwohl die Payload identisch aussieht?

Fast immer, weil der Body vor dem Hashing geparst und neu serialisiert wurde. JSON-Schlüsselreihenfolge, Whitespace und Unicode-Escaping ändern sich während Parse/Stringify, sodass die Bytes nicht mehr mit dem übereinstimmen, was der Sender signiert hat. Erfasse den rohen Body und hashe diesen.

Sollte ich 200 vor oder nach der Verarbeitung des Events zurückgeben?

Nach der dauerhaften Speicherung, vor der schweren Arbeit. Wenn du zuerst verarbeitest, verursachen langsame nachgelagerte Aufrufe Timeouts, der Sender versucht es erneut, und du bekommst Duplikate. Reihen Sie das Event in eine Queue ein, antworte sofort und verarbeite asynchron.

Reicht HTTPS, um einen Webhook-Endpunkt abzusichern?

Nein. TLS schützt Daten während der Übertragung, beweist aber nicht, wer die Anfrage gesendet hat – jeder kann an eine öffentliche URL POSTen. HMAC-Signierung mit einem gemeinsamen Secret authentifiziert den Sender, und ein signierter Zeitstempel verhindert das Replay abgefangener Anfragen.

Fazit

Behandle Webhooks wie jede nicht vertrauenswürdige Eingabe: Verifiziere die Signatur über rohe Bytes, prüfe den Zeitstempel, dedupliziere anhand der Event-ID und bestätige erst, nachdem das Event sicher gespeichert ist. Dann sind Retries keine Quelle doppelter Abbuchungen mehr, sondern das, was sie sein sollten – ein Sicherheitsnetz.