Shipmind Labs

A payment webhook is not an event. It is a retry with an opinion about the past.

We have built payment services on card rails and account-to-account flows for years, and the same failure turns up in almost every codebase we inherit: the handler reads the payload, trusts it, and updates the balance.

That holds until the provider does what every provider does. It retries the same notification because your 200 came back slow. It delivers "captured" before "authorized" because two workers fired in parallel. After an outage it resends a three-hour-old status and overwrites a refund that already happened.

None of that is a bug on their side. Delivery is at-least-once and unordered by design, and the documentation usually says so in one line nobody reads.

What we do instead: the endpoint stores the raw payload keyed by the provider's event id and returns immediately. Processing happens after, once, guarded by that id. State changes are applied as transitions rather than assignments, so a transaction that is already refunded rejects a late "captured" instead of accepting it. And the provider's timestamp, not our arrival time, decides what is stale.

The reframing that helps: the webhook is a hint that something changed, not the description of what it changed to. When money is involved, that hint triggers a reconciliation against the provider's own API, and that answer wins.

If you run payment integrations in production, you have probably already picked a side here: process webhooks inline, or store first and reconcile after.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com