Billing-Webhooks: Signaturen, Retries, Reihenfolge und Replay
Ein Kunde zahlt per SEPA-Lastschrift. Der Payment-Provider meldet payment.succeeded, Ihr Handler markiert die Rechnung als bezahlt und schaltet den Account frei. Vier Tage später kommt payment.failed, weil die Bank die Lastschrift zurückgegeben hat. Ihr Handler hat aber ein Problem: Wegen eines Retries kommt das alte payment.succeeded ein zweites Mal an, nach dem Fehlschlag. Wer Ereignisse in Eingangsreihenfolge anwendet, setzt die Rechnung wieder auf bezahlt. Mahnung bleibt aus, der Zugang bleibt offen.
An Webhooks hängen in der Abrechnung Zahlungsstatus, Mahnläufe, Zugriffsrechte und Buchungen. Dieser Beitrag behandelt die vier Stellen, an denen Webhook-Integrationen in der Praxis brechen: Signaturprüfung, Wiederholungen, Reihenfolge und das Nachholen verpasster Ereignisse.
Signaturen prüfen nach Standard Webhooks
Ein Webhook-Endpunkt ist eine öffentlich erreichbare URL. Jeder, der sie kennt, kann ihr ein invoice.paid schicken. Deshalb muss jede Zustellung signiert sein und jede Signatur geprüft werden, bevor der Inhalt irgendetwas auslöst.
Viele Anbieter haben eigene Signaturverfahren. Die offene Spezifikation Standard Webhooks vereinheitlicht das mit drei Headern: webhook-id (eindeutige Nachrichtenkennung), webhook-timestamp (Unix-Zeit) und webhook-signature (HMAC-SHA256 über ID, Zeitstempel und Body, Base64-kodiert, mit Versionspräfix v1,). Eine Prüfung in Python sieht so aus:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
msg_id = headers["webhook-id"]
ts = int(headers["webhook-timestamp"])
if abs(time.time() - ts) > tolerance:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{ts}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, sig.split(",", 1)[1])
for sig in headers["webhook-signature"].split(" ")
if sig.startswith("v1,")
)
Drei Details entscheiden, ob das in Produktion hält:
- Rohdaten verwenden. Die Signatur gilt für die exakten Bytes des Bodys. Wenn Ihr Framework JSON erst parst und dann neu serialisiert, ändert sich die Reihenfolge von Schlüsseln oder Leerzeichen, und jede Prüfung schlägt fehl.
- Konstant vergleichen. Konstante Vergleichszeit über
hmac.compare_digest, damit sich die Signatur nicht über Antwortzeiten erraten lässt. - Mehrere Signaturen akzeptieren. Der Header kann bei einer Schlüsselrotation alte und neue Signatur gleichzeitig enthalten. Wer nur die erste prüft, verliert während der Rotation Zustellungen.
Der Zeitstempel mit Toleranzfenster schützt vor Replay-Angriffen: Eine abgefangene, gültige Nachricht lässt sich nach fünf Minuten nicht mehr einspielen. Achten Sie auf synchrone Server-Uhren, sonst lehnen Sie korrekte Zustellungen ab.
Wiederholungen sind der Normalfall
Fast alle Anbieter liefern Webhooks mindestens einmal aus. Wenn Ihr Endpunkt nicht innerhalb weniger Sekunden mit einem 2xx-Status antwortet, wird die Nachricht wiederholt, oft über Stunden oder Tage mit wachsenden Abständen. Ein Handler, der beim ersten Aufruf 20 Sekunden lang eine Rechnung erzeugt und dann in den Timeout läuft, erzeugt beim zweiten Aufruf die nächste.
Die robuste Struktur hat zwei Schritte:
- Annehmen: Signatur prüfen, die Nachricht mit
webhook-idals eindeutigem Schlüssel in eine Tabelle schreiben, sofort 200 zurückgeben. Bei einem Schlüsselkonflikt ebenfalls 200, denn die Nachricht ist bereits angekommen. - Verarbeiten: Ein Worker liest offene Nachrichten aus der Tabelle und wendet sie an. Fehler hier führen zu einem internen Retry, nicht zu einem erneuten Versand durch den Anbieter.
Die eindeutige Nachrichten-ID ist dabei die Grundlage der Deduplizierung. Verlassen Sie sich nicht auf den Inhalt: Zwei verschiedene Ereignisse können denselben Betrag und dieselbe Rechnung betreffen. Wie dieselbe Logik für Nutzungsdaten aussieht, die nicht per Webhook kommen, sondern von Ihrem Produkt gesendet werden, beschreibt der Beitrag zu idempotenten Usage-Events.
Antworten Sie mit einem Fehlerstatus nur, wenn eine Wiederholung tatsächlich helfen kann, etwa mit 503, weil Ihre Datenbank kurz nicht erreichbar war. Eine ungültige Signatur ist dagegen kein temporärer Fehler; dafür passt 401 oder 400. Die Bedeutung der Statusklassen legt RFC 9110 fest, aber wie ein Anbieter auf 4xx reagiert, ist unterschiedlich: Manche wiederholen auch dann, manche deaktivieren den Endpunkt nach vielen Fehlern. Lesen Sie die Retry-Regeln des Absenders nach.
Reihenfolge kommt aus dem Objekt, nicht aus der Zustellung
Zurück zum Eingangsbeispiel. Webhooks werden parallel und mit Wiederholungen zugestellt; die Ankunftsreihenfolge sagt nichts über die Reihenfolge der Ereignisse. Es gibt zwei Muster, damit umzugehen.
Erstens: Versionen vergleichen. Wenn jede Nachricht einen Zeitstempel der Zustandsänderung oder eine Versionsnummer des Objekts trägt, speichern Sie pro Rechnung die höchste bereits angewandte Version und verwerfen ältere Nachrichten.
| Ankunft | Ereignis | Objektversion | Gespeicherte Version | Aktion |
|---|---|---|---|---|
| 1 | payment.succeeded |
3 | 2 | anwenden, Rechnung bezahlt |
| 2 | payment.failed |
5 | 3 | anwenden, Rechnung offen, Mahnlauf startet |
| 3 | payment.succeeded (Retry) |
3 | 5 | verwerfen, älter als gespeicherter Stand |
Zweitens: Webhooks nur als Hinweis behandeln. Der Handler nimmt die Nachricht an und lädt den aktuellen Stand des Objekts über die API nach (GET /invoices/{id}). Dann ist egal, in welcher Reihenfolge die Hinweise eintreffen, denn Sie arbeiten immer mit dem aktuellen Zustand. Der Preis dafür sind zusätzliche API-Aufrufe und Rate-Limits.
Für Zahlungs- und Rechnungsstatus empfiehlt sich das zweite Muster, weil dort ein falscher Zwischenstand teuer ist. Für Ereignisse, die nur protokolliert werden, genügt meist das erste.
Verpasste Ereignisse nachholen
Irgendwann ist Ihr Endpunkt länger nicht erreichbar, als der Anbieter wiederholt, oder ein Deployment hat die Signaturprüfung gebrochen und eine Stunde lang jede Nachricht abgelehnt. Dann brauchen Sie einen Weg, verpasste Ereignisse zu finden.
Prüfen Sie beim Anbieter, ob es einen Endpunkt gibt, der Ereignisse seit einem Zeitpunkt auflistet, oder eine Funktion, fehlgeschlagene Zustellungen erneut anzustoßen. Ohne eines von beiden bleibt nur der Abgleich: alle Rechnungen mit Änderungen im fraglichen Zeitraum laden und mit Ihrem Stand vergleichen.
Ein Abgleichjob lohnt sich ohnehin. Laufen Sie täglich über die Rechnungen mit Status open oder past_due und vergleichen Sie den Status mit dem des Anbieters. Abweichungen sind ein Signal, dass Webhooks verloren gehen, bevor ein Kunde es meldet. Für die Auswahl eines Anbieters gehören genau diese Fragen in den Trial; dazu mehr im Beitrag zur Auswahl einer API-basierten Abrechnungsplattform.
Mandantentrennung im Handler
In einem Multi-Tenant-Produkt gehört jedes Ereignis zu genau einem Ihrer Kunden. Diese Zuordnung darf nicht aus einem Feld im Payload stammen, das ein Angreifer setzen könnte, sondern aus einer serverseitig gespeicherten Verknüpfung, etwa der Zuordnung einer Provider-Kunden-ID zu Ihrem Mandanten. Verwenden Sie, wenn der Anbieter das unterstützt, pro Mandant oder pro Umgebung ein eigenes Signatur-Secret. Ein Leck in der Testumgebung öffnet dann nicht die Produktion.
Prüfliste vor dem Go-live
- Signaturprüfung auf Rohdaten, mit Toleranzfenster und Unterstützung mehrerer Signaturen
- Nachrichten-ID als eindeutiger Schlüssel, Duplikat ergibt 200 ohne Wirkung
- Antwort innerhalb weniger Sekunden, Verarbeitung asynchron
- Reihenfolge über Objektversion oder Nachladen, nie über Ankunftszeit
- Abgleichjob für offene Rechnungen und ein dokumentierter Weg, verpasste Ereignisse nachzuladen
- Mandantenzuordnung serverseitig, Secrets getrennt nach Umgebung
- Testfall: dieselbe Nachricht zweimal, vertauscht und nach zehn Minuten zustellen
Die Liste gilt für jeden Absender. Ist Kontier der Absender, ist die Kontier-Entwicklerdokumentation der Startpunkt.
Kein Steuer- oder Rechtsrat. Dieser Beitrag beschreibt die Rechtslage und Praxis allgemein. Für Ihren Einzelfall fragen Sie Ihre Steuerberatung. Stand: