How do you make payment provider webhooks reliable?
Verify, deduplicate on event id, read fields through a version-tolerant helper, and never return 200 for work you did not do. Three failures from production.
Answer
Verify the signature, insert the event id into a processed-events table before doing anything, and read payload fields through a helper that tolerates more than one API version shape. The failure that cost us most was none of those: a handler that threw, caught its own error, and returned 200 — so the provider never retried.
Leutrim Miftaraj
Founder, SubTracker · Updated September 5, 2026
The short answer
Webhook reliability is three separate problems that get treated as one. Duplicates need stored state, out-of-order delivery needs a version or timestamp check, and provider API changes need a tolerant reader. A generic try-catch around the whole handler solves none of them and hides all three.
The three failures, from production
1. A field moved between API versions. Stripe's 2025-03-31 release relocated "current_period_end" off the Subscription object. Our handler constructed a Date from undefined, threw a RangeError, caught it, and returned 200. Plan and status updates silently stopped applying. Nothing alerted, because from the provider's side every delivery succeeded.
2. Duplicate delivery. Providers retry, and at-least-once is the documented contract. A handler that grants entitlement without deduplicating grants it twice — harmless for an idempotent update, not harmless if you are incrementing anything.
3. Out-of-order delivery. A cancellation arriving before the update that preceded it, leaving the account in the wrong state. Rare, real, and undetectable without a comparison against what you already have.
Each needs a different mechanism. The single try-catch that seems to address all of them addresses none.
Deduplicate on the event id, before the work
""`sql create table processed_webhook_events ( id text primary key, -- the provider's event id type text not null, received_at timestamptz not null default now(), status text not null default 'processing', error text ); ""`
""`ts const { rows } = await db.query( `insert into processed_webhook_events (id, type) values ($1, $2) on conflict do nothing returning id`, [event.id, event.type], ) if (rows.length === 0) { // Already seen. Acknowledge and stop — the provider is retrying. return new Response('duplicate', { status: 200 }) } ""`
Insert first, work second. The primary key does the work; no lock, no race. Update "status" to "done" or "error" afterwards so the table is also the audit log.
Keeping errored rows matters: it is the only place that answers "which events did we fail to process last Tuesday".
Read fields through a tolerant helper
Version drift is not an edge case; it is a scheduled event that you find out about when something breaks.
""`ts /** * The period end moved off the Subscription object in Stripe's 2025-03-31 * release. Reading it through one helper means the next move is one change, * not a search across the handler. */ export function periodEnd(sub: any): Date | null { const raw = sub?.current_period_end ?? sub?.items?.data?.[0]?.current_period_end ?? null return typeof raw === 'number' ? new Date(raw * 1000) : null } ""`
Two related habits. Pin the API version explicitly in the client configuration rather than taking the account default, so an account-level upgrade does not change payload shapes under a running deployment. And treat a null from the helper as a condition to handle, not as a value to pass into a Date constructor.
Never return 200 for work you did not do
The rule that would have caught the first failure on day one.
A 200 tells the provider the event is handled and stops the retry. If your handler caught an exception and returned 200 anyway, you have converted a recoverable failure into a permanent one — and destroyed the evidence, because the provider's dashboard shows a successful delivery.
""`ts try { await handle(event) await markDone(event.id) return new Response('ok', { status: 200 }) } catch (err) { await markError(event.id, String(err)) // 500 so the provider retries. The event row records why. return new Response('handler failed', { status: 500 }) } ""`
The one exception is a permanently unprocessable event — an event type you do not handle, or a payload referencing a deleted object. Acknowledge those with 200 and record the reason, because retrying them forever is not useful either. Make that decision explicitly per case rather than as a blanket catch.
Ordering
For state transitions, compare against what you have before applying.
Most providers include a timestamp on the event, and subscription objects carry their own version or updated field. If the incoming state is older than what is stored, discard it and record that you did.
Simpler alternative that is often enough: treat the webhook as a signal rather than as data. On receipt, fetch the current object from the provider's API and apply that. Slower, one extra call, and immune to ordering entirely — for a subscription tracker's volume it is usually the right trade.
Die ehrliche Bau-oder-Kauf-Linie
Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.
Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.
Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.
Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.
Was SubTracker damit macht
Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.
SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.
Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.
Frequently asked questions
How do you make a webhook handler idempotent?+
Insert the provider's event id into a processed-events table with on-conflict-do-nothing before doing any work. If no row comes back, the event has been seen and the handler stops. The primary key does the work with no locking.
Should a failing webhook handler return 200?+
No. A 200 tells the provider the event is handled and stops the retry, turning a recoverable failure into a permanent one and hiding it — the provider's dashboard shows a successful delivery. Return 500 and record why.
How do you handle payment API version changes?+
Pin the API version explicitly rather than taking the account default, and read fields that have moved through a single tolerant helper. The next move is then one change instead of a search across the handler.
How do you handle out-of-order webhook delivery?+
Compare the incoming state against what you have stored and discard older events, or treat the webhook purely as a signal and fetch the current object from the provider's API. The second costs one call and is immune to ordering entirely.
