Skip to content
Payment webhooks arrive twice, out of order, and sometimes never. Design for all three
article3 min read281 viewsOct 2, 2026

Payment webhooks arrive twice, out of order, and sometimes never. Design for all three

Arjun Mehta@arjunmehta

October 2, 2026 · 3 min read

#1 in Ledger Lines

281 views5 comments

Every payment gateway webhook doc says some version of "we may send the same event more than once". What they say less loudly: events can arrive out of order, and sometimes they dont arrive at all. Our first webhook handler assumed none of this. Here is the version that survived.

1. Twice: store the event id

Every event has an id. Insert it into a processed_events table with a unique constraint before doing anything else. If the insert fails on conflict, return 200 and stop. Return 200, not 409, otherwise the gateway keeps retrying something you already handled.

2. Acknowledge fast, process later

Verify the signature, store the raw event, return 200. Then process from a queue. Gateways have short timeouts, often a few seconds, and if your handler does DB updates, sends emails and calls three services inline, one slow day will make the gateway retry everything and you are back to problem 1, now with extra load.

3. Out of order: state machine, not last write wins

We once got payment.captured and then, 2 seconds later, an older payment.authorized that was being retried. Our handler set the status back to authorized. The order looked unpaid and the customer got a "complete your payment" email for something they had paid. Fix: payment status is a state machine with allowed transitions. Authorized can go to captured, captured can never go back to authorized. An event that would move backwards is logged and ignored.

const allowed = {
  created:    ['authorized', 'failed'],
  authorized: ['captured', 'failed', 'voided'],
  captured:   ['refunded', 'partially_refunded'],
}

Even better, when an event arrives, fetch the current object from the gateway API and use that as the truth. The event then is just a notification that "something changed", which makes the order irrelevant. Costs one API call per event, worth it for payments.

4. Never: poll for the ones that went missing

Webhooks fail. Your endpoint was down during a deploy, a firewall rule changed, the gateway had an incident. Run a job every 10-15 minutes that finds payments still pending after N minutes and asks the gateway for their status. This job saved us more than once when webhooks silently stopped for a few hours.

5. Verify the signature on the raw body

Compute the HMAC on the exact raw bytes you received, not on JSON you parsed and serialized again. Key order and whitespace change, the signature fails, someone "temporarily" disables verification, and it stays disabled for a year. Compare with a constant time function and reject old timestamps if the gateway sends one.

Most of this is boring plumbing. But each point is a production incident we actually had, so maybe it saves you one.

Written by

Arjun Mehta

Backend engineer at a lending startup in Bengaluru. Payments, ledgers, reconciliation, and the occasional 3am settlement file that does not add up. Postgres fan. Will talk about idempotency keys until you leave.

Get the best of #ledger_lines weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

5

Sign in to join the conversation.

Sign in to comment
Chloe Barnes@chloebuilds#2 in Ledger Lines
point 2 is my black friday story. handler did everything inline, timed out, stripe retried, one dog groomer got 3 receipts and asked if she was being charged 3 times. she was not. she was however very confused
2 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Haha the receipt email is always the one that goes out 3 times. Did you move to a queue after?
0 net votes
Fatima Al-Sayed@fatima_appsec#3 in Ledger Lines
Good list. On 5: keep the webhook secret per environment, and rotate it. I have seen staging and prod share one secret, so anyone with staging access could forge production events. Fetching the object from the API like you say in 3 also protects you if the signature check is ever broken.
1 net votes
Ben Carter@bencarter_web1 rep in Ledger Lines
webhooks arriving twice? sounds like they're really hooked on you ok sorry. real question: for small clients on shared hosting with no queue, is "insert raw event, return 200, cron processes every minute" good enough?
1 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Yes, for small volume that is a perfectly fine queue. A table plus a cron is a queue, people forget that. Just make the processing idempotent.
2 net votes