Kurz gefasst
Webhook-Idempotenz ist Sache des Empfängers, weil Zustellung über ein Netz nur at-least-once zusagen kann. Ein Zustellzyklus von Paymos umfasst 11 Versuche über rund 16 Stunden, danach lässt sich ein Ereignis von Hand nachsenden. Fangen Sie doppelte Zustellungen über die evt_-Kennung aus X-Webhook-Id ab und ordnen Sie Bestellungen über die Ressourcen-Kennung in data zu. Der erste Wert unterscheidet sich je Endpunkt, der zweite nie.
Ein Zahlungs-Webhook kann zweimal ankommen. Auf der Leitung ist dagegen nichts zu machen, denn eine Zustellung über ein Netz lässt sich nur at-least-once zusagen, also mindestens einmal: Wer innerhalb seines Zeitlimits keine Antwort bekommt, kann eine verlorene Anfrage nicht von einer verlorenen Antwort unterscheiden und fragt erneut. Ein Zustellzyklus von Paymos umfasst 11 Versuche über rund 16 Stunden, und danach lässt sich ein Ereignis von Hand nachsenden.
Harmlos wird die zweite Zustellung erst beim Empfänger. Jede Zustellung trägt ihre eigene
evt_…-Kennung in X-Webhook-Id, die Rechnung in data trägt eine andere, und die ändert sich
nie.
Fangen Sie Doppel über die erste ab, buchen Sie Bestellungen über die zweite, dann kostet eine Wiederholung einen Index-Zugriff.
Was der Zustellvertrag zusagt, steht in der Dokumentation dazu. Hier geht es darum, warum er diese Form hat und was er Ihrem Code abverlangt. Welche Ziele überhaupt angenommen werden, entscheidet die Prüfung der Callback-URL schon vorher.
Welche Kennung fängt Doppel ab, welche ordnet zu?
Zwei Identitäten reisen in einer Zustellung mit, und nach der falschen zu greifen ist der Fehler, gegen den dieses Design gebaut ist.
X-Webhook-Id trägt denselben Wert wie event_id im Rumpf, ein evt_…, und benennt die
Zustellung: die Kopie eines Übergangs für genau einen Endpunkt. Jede Wiederholung trägt ihn. Ein
Nachsenden von Hand behält ihn ebenfalls.
Die Ressourcen-Kennung in data ist ein inv_…, wdr_… oder pcd_… und benennt die Sache, der
das Geld passiert ist. Jeder Endpunkt, der dieses Ereignis abonniert hat, sieht dieselbe, und jeder
spätere Status derselben Ressource trägt sie auch. Den vollständigen Umschlag zeigt die
Payload-Referenz.
Daraus folgen zwei Arten, es falsch zu machen. Ein Händler mit zwei Endpunkten, der seine
Bestelltabelle über event_id schlüsselt, sieht für eine Zahlung zwei verschiedene Kennungen und
bucht sie zweimal. Ein Händler, der seine Zustellungen über die Rechnungs-ID dedupliziert,
blockiert für diese Rechnung die zweite Zustellung, und das ist invoice.paid nach
invoice.confirming. Die Bestellung geht dann nie raus.
Stripe kommt von der anderen Seite zum gleichen Schnitt: Ereignis-Kennungen protokollieren, um
Wiederholungen zu erkennen, und für zwei Ereignisobjekte, die dieselbe Sache beschreiben, die
Objekt-ID in data.object zusammen mit dem Ereignistyp heranziehen.
Warum kommt ein Ereignis nicht garantiert genau einmal an?
Weil das über ein Netz niemand zusichern kann. Zwei Parteien, die nur über einen Kanal reden, der Nachrichten verliert, erreichen keine Gewissheit über den Zustand der jeweils anderen. Bewiesen ist das seit 1975, es ist also keine Frage des Budgets.
Drei Jahre später gab Jim Gray dem Ganzen den Namen, unter dem es bis heute läuft: das Paradox der zwei Generäle.
Sehen Sie sich an, was daraus für eine Zustellung folgt. Der Absender öffnet die Anfrage, schreibt den Rumpf, wartet. Das Zeitlimit von 10 Sekunden je Versuch läuft ab, und auf dem Socket steht nichts.
Hat der Empfänger die Anfrage nie gesehen? Oder hat er die Signatur geprüft, die Bestellung gutgeschrieben und die Antwort auf dem Rückweg verloren? Von der Absenderseite ist beides dieselbe Stille.
Damit bleibt eine Wahl, von der nur eine Hälfte ehrlich mit Geld umgeht. Einmal senden, dann erreicht eine bezahlte Rechnung das Bestellsystem manchmal nie. Erneut senden, dann läuft ein Handler manchmal zweimal über eine Zahlung, und gegen diesen einen Fehlerfall kann ein Empfänger etwas bauen.
Eine Krypto-Eigenheit ist daran nichts. Google dokumentiert at-least-once als Voreinstellung für jeden Abonnementtyp in Pub/Sub und schreibt Abonnenten Idempotenz ins Pflichtenheft.
Stripe schreibt, dass Endpunkte dasselbe Ereignis gelegentlich mehrfach erhalten und die Antwort darauf ein Protokoll der verarbeiteten Kennungen ist.
Unterschiedlich ist zwischen Produkten nur der Preis einer Wiederholung. Ein doppelter
Analytics-Ping verbiegt eine Kurve. Ein doppeltes invoice.paid verschickt ein zweites Paket oder
erzeugt einen zweiten Lizenzschlüssel, und das Bestellsystem merkt nichts davon, weil jeder
einzelne Lauf für sich richtig aussah.
RFC 9110 ist beim Wort selbst genau: Eine Methode ist idempotent, wenn die beabsichtigte Wirkung mehrerer identischer Anfragen dieselbe ist wie die einer einzigen.
Das beschreibt, was der Empfänger mit einem Ereignis tut, und nicht, was der Absender auf die Leitung legt. Ein Header kann einen Schlüssel tragen; gleich machen kann die Wirkung nur der Empfänger.
Was das Outbox-Muster nicht löst
Doppelte Zustellungen, und das ist kein Mangel des Musters.
Ein Zustandswechsel und eine Nachricht müssen zusammen hinaus, und keine Transaktion spannt sich über eine Datenbank und ein Netz. Schreiben Sie die Zeile zuerst, verliert ein Absturz vor dem Senden die Nachricht. Senden Sie zuerst, verkündet ein Absturz vor dem Schreiben etwas, das nie passiert ist. Das ist das Dual-Write-Problem, und die Standardantwort darauf heißt transaktionale Outbox: Die Nachricht landet in derselben Datenbank, in derselben Transaktion wie der Sachverhalt, den sie beschreibt, und ein Relay trägt sie hinaus.
Lesen Sie die Zusage genau, sie ist schmaler als ihr Ruf. Die Nachricht existiert genau dann, wenn die Transaktion festgeschrieben wurde. Die andere Hälfte führt Richardson selbst unter den Problemen des Musters auf: Das Relay kann eine Nachricht veröffentlichen, abstürzen, bevor es diesen Schritt festhält, und sie beim Neustart erneut veröffentlichen.
Der Tausch dahinter lohnt sich trotzdem. Eine verschwundene Nachricht hinterlässt nirgends eine Spur. Eine wiederholte kommt mit der Kennung an, die sie beim ersten Mal hatte, und eine Kennung kann ein Empfänger indizieren. Ob ein bestimmter Absender eine Outbox betreibt, ist von außen ohnehin nicht zu sehen. An Ihrem Endpunkt kommt nur die Eigenschaft an, die dabei herauskommt.
Warum wird die Zeile vor der Arbeit geschrieben?
Das ist eine Regel für den Absturz, und sie zahlt sich in einem schmalen Fenster aus.
Ein Handler, der erst die Bestellung ausführt und danach das Ereignis festhält, lässt zwischen beidem eine Lücke. Wird der Prozess genau dort abgeräumt, steht eine ausgelieferte Bestellung da, von der kein System weiß, und die nächste Zustellung liefert sie noch einmal aus.
Drehen Sie den Handler um. Signatur prüfen, Zustellung einfügen, antworten. Den Rest erledigt ein Worker nach Ihrem eigenen Zeitplan.
create table webhook_delivery (
event_id text primary key, -- evt_…, diese Zustellung
resource_id text not null, -- inv_… / wdr_… / pcd_…, die Zahlung
event_type text not null,
body jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);
insert into webhook_delivery (event_id, resource_id, event_type, body)
values ($1, $2, $3, $4)
on conflict (event_id) do nothing
returning event_id;
Kommen null Zeilen zurück, liegt diese Zustellung bereits vor: 2xx antworten und aufhören, denn
es ist nichts mehr zu entscheiden. Kommt eine Zeile zurück, gehört die Arbeit Ihnen, und das 2xx
quittiert die empfangenen Bytes statt zu behaupten, dass etwas ausgeliefert wurde.
Dieser Schnitt hält den Handler außerdem innerhalb der 10 Sekunden je Versuch. Der Fremdaufruf, der hängt, und das Erzeugen eines Lizenzschlüssels, das an einem schlechten Tag stockt, gehören beide hinter eine Queue mit eigenen Wiederholungen.
Wie lange kommt ein Ereignis zurück?
Elf Versuche, mit Abständen von einer Minute am Anfang bis zu acht Stunden am Ende; der letzte fällt damit rund 16 Stunden nach dem ersten.
Der Zustellplan ist Versuch für Versuch veröffentlicht.
Für Ihre eigene Ausfallzeit stehen darin zwei verschiedene Nachrichten. Ein Deploy fällt nicht auf, weil die unteren Sprossen Minuten auseinanderliegen und das Ereignis zurückkommt, bevor jemand ein Dashboard öffnet. Ein Datenbankausfall über einen ganzen Arbeitstag fällt auf: Die Ereignisse, deren Zyklus darin abgelaufen ist, gelten als fehlgeschlagen.
Dem Endpunkt passiert dabei nichts. Scheitert der elfte Versuch, wird allein das Ereignis als fehlgeschlagen markiert; der Endpunkt bleibt unangetastet, aktiv und weiter abonniert.
Mitgezählt wird nichts, und es gibt nichts wieder einzuschalten. Ein Empfänger, der einen Nachmittag lang weg war, kommt zu einem lebenden Endpunkt und einer Liste fehlgeschlagener Ereignisse zurück, die auf ein Nachsenden warten.
Das Dashboard zeigt die 100 jüngsten Ereignisse, das neueste zuerst, mit Status, Zustellversuchen und dem nächsten Versuch; weiter zurück reicht es nicht.
Behandeln Sie es als Fenster auf das letzte Stück Verkehr. Das Archiv ist Ihre eigene Zustelltabelle.
Was macht ein Nachsenden sicher?
Nachsenden löst jemand im Betrieb aus, es betrifft fehlgeschlagene Ereignisse, und es behält die Kennung der jeweiligen Zustellung.
Was Ihre Tabelle schon kennt, fängt sie damit an der Tür ab, zum Preis eines Index-Zugriffs.
Interessant ist der andere Fall. Ein Endpunkt, der die ganze Störung über Zustellungen abgelehnt hat, steht mit einer leeren Tabelle da, und nach der Reparatur ist jedes nachgesendete Ereignis neu für ihn. Der Geschäftsvorfall dahinter kann längst richtig stehen, weil jemand im Support ins Dashboard gesehen und die Bestellung als bezahlt markiert hat oder ein nächtlicher Abgleich schneller war.
Eine Tabelle mit gesehenen Zustellungen sieht davon nichts. Sie weiß, welche sie gelesen hat, und sonst nichts. Der Schutz für diesen Fall sitzt am Zustandswechsel: Eine Rechnung, die schon als bezahlt gebucht ist, wird nicht noch einmal gebucht, und diese Prüfung gehört in dieselbe Transaktion wie die Buchung, die sie schützt.
Was passiert beim Wechsel des Webhook-Geheimnisses?
24 Stunden lang trägt der Signatur-Header zwei v1-Werte statt eines, und eine Zustellung ist
gültig, wenn sie zu einem davon passt.
Genau dieses Fenster lässt den Empfänger das neue Geheimnis nach seinem eigenen Deploy-Plan übernehmen.
Ein Empfänger, der v1 als Feld liest statt als Liste, verliert in diesem Fenster jede Zustellung.
Jeder dieser Fehlschläge ist ein Versuch auf der Leiter. Sechzehn Stunden später gelten diese
Ereignisse als fehlgeschlagen, und das erste sichtbare Symptom ist meist eine Bestellung, die nie
rausging.
Lesen Sie v1 also als Liste, vergleichen Sie jeden Kandidaten mit einer laufzeitunabhängigen
Funktion und akzeptieren Sie beim ersten Treffer. Was genau gehasht wird und in welcher Reihenfolge,
steht auf der Seite zur Signaturprüfung. Jedes offizielle Paymos-SDK
verhält sich bereits so, und die Konformitätstests der SDKs halten einen Header mit zwei Werten als
Testvektor fest. Wer auf einem SDK aufsetzt, erbt das Verhalten.
Wie weisen Sie nach, dass Ihr Empfänger wiederholungssicher ist?
Durch Nachsenden, nicht durch das Lesen von Code. Geprüft wird eine Aussage über den zweiten Lauf, und genau den erzeugt ein Unit-Test selten.
Der API-Playground im Dashboard sendet echte HMAC-signierte Anfragen mit den Zugangsdaten des Händlers, ausschließlich in der Sandbox, und sein Webhook-Playground erzeugt eine echte Zustellung.
Damit läuft die ganze Prüfung gegen einen Staging-Empfänger:
- Eine Zustellung auslösen, den Handler durchlaufen lassen und die erzeugte Bestellzeile notieren.
- Dasselbe Ereignis erneut auslösen. Es sollte Ihre Tabelle erreichen, die Kennung finden und
2xxzurückgeben, ohne die Bestellung anzufassen. - Den Empfänger hängen lassen, bis das Zeitlimit von 10 Sekunden und die Minute Wartezeit danach vorbei sind, sodass die Wiederholung eintrifft, während der erste Lauf noch arbeitet. Für diese Kollision ist der eindeutige Index da.
- Ein fehlgeschlagenes Ereignis nach der Reparatur nachsenden und bestätigen, dass eine bereits gebuchte Bestellung gebucht bleibt.
- Die eigene Zustelltabelle gegen die Rechnungen halten, die die API als bezahlt meldet. Eine fehlende Zeile ist ein Abonnement- oder Firewall-Problem, eine doppelte Auslieferung ein Schlüsselproblem.
Fünf Prüfungen, ein Nachmittag. Wer sie hinter sich hat, liest Versuch elf wie Versuch eins:
Kennung liegt in der Tabelle, 2xx geht raus, im Lager bewegt sich nichts.
| Schlüssel im Empfänger | Wiederholung derselben Zustellung | Zweiter Endpunkt, eine Zahlung | confirming, danach paid | |
|---|---|---|---|---|
| event_id für beide Aufgaben | Blockiert | Bestellung doppelt gebucht | Beide verarbeitet | |
| Rechnungs-ID für beide Aufgaben | Blockiert | Blockiert | paid fällt weg | |
| event_id fängt Doppel ab, Rechnungs-ID ordnet zu | Blockiert | Blockiert | Beide verarbeitet |
Häufige Fragen
Warum habe ich denselben Zahlungs-Webhook zweimal bekommen?
Zustellung ist at-least-once. Ein Versuch, der ins Zeitlimit läuft, wird auch dann wiederholt, wenn der Empfänger ihn verarbeitet hat, denn eine verlorene Anfrage und eine verlorene Antwort sehen von der Absenderseite gleich aus. Ein Nachsenden von Hand erzeugt ebenfalls eine Wiederholung.
Dedupliziere ich über event_id oder über die Rechnungs-ID?
Zustellungen über event_id, den gleichen Wert wie im Header X-Webhook-Id. Die Bestellung selbst über die Rechnungs-ID. event_id unterscheidet sich zwischen zwei Endpunkten, die dieselbe Zahlung abonniert haben; die Rechnungs-ID bleibt über alle Endpunkte und alle Status hinweg dieselbe.
Was soll mein Handler für ein bereits verarbeitetes Ereignis antworten?
Ein 2xx, sofort. Eine erkannte Wiederholung ist eine erfolgreiche Zustellung. Ein Fehler setzt das Ereignis ohne Grund zurück auf die Wiederholungsleiter.
Bekommt ein nachgesendetes Ereignis eine neue Kennung?
Nein. Ein Nachsenden behält die evt_-Kennung dieser Zustellung, sodass ein Empfänger, der sie beim ersten Mal gespeichert hat, die Wiederholung mit einem Index-Zugriff erkennt.
Wie lange wird ein fehlgeschlagener Webhook wiederholt?
11 Versuche über rund 16 Stunden, mit Abständen von einer Minute bis zu acht Stunden. Danach gilt das Ereignis als fehlgeschlagen und lässt sich von Hand nachsenden. Der Endpunkt selbst bleibt aktiv.
Wann eine Bestellabwicklung über Webhooks NICHT passt
- Läuft der Empfänger nur auf einem Laptop oder in einem privaten Netz, hat die Zustellung kein Ziel. Ein Ziel ohne öffentliche Route wird abgewiesen, und einer Weiterleitung dorthin folgt die Zustellung nicht. Fragen Sie während der Entwicklung den Rechnungsstatus über die API ab.
- Kommen täglich eine Handvoll Zahlungen an und sieht ohnehin ein Mensch darauf, sind Zustelltabelle, Queue und Schlüssel mehr Maschinerie, als das Volumen trägt.
- Kann Ihr Bestellsystem einen wiederholten Zustandswechsel nicht abweisen, reparieren Sie zuerst das. Ein Schlüssel vor einem Weg, der zweimal laufen kann, verengt das Fenster und schließt es nicht.
- Wollen Sie den Strom eines einzelnen Ereignisnamens, gibt es den nicht. Abonniert wird je Kategorie, und ein Endpunkt für Rechnungen erhält jedes Rechnungsereignis. Verzweigen Sie im Handler.
Quellen
- 1. HTTP Semantics (RFC 9110), Abschnitt 9.2.2 Idempotent Methods (accessed 2026-09-15)
- 2. Google Cloud Pub/Sub — Subscription overview (at-least-once delivery) (accessed 2026-09-15)
- 3. Stripe — Receive Stripe events in your webhook endpoint (accessed 2026-09-15)
- 4. Paymos — Wiederholungen und Nachsenden (accessed 2026-09-15)
- 5. Chris Richardson — Pattern: Transactional outbox (accessed 2026-09-15)
Zuletzt geprüft 15. Sept. 2026


