Making a Payment Webhook Idempotent Before It Charges Someone Twice

Making a Payment Webhook Idempotent Before It Charges Someone Twice

October 12, 2026

A customer pays. The gateway calls your webhook, you activate the subscription, everything is correct.

Then the same callback arrives again twenty seconds later, and the subscription is extended by a second year.

Duplicate delivery is normal

Table of four reasons a payment webhook is delivered more than once
Duplicate delivery is normal, not an error.

Every payment gateway promises at-least-once delivery, which means exactly what it says. Your handler will be called more than once for the same payment and that is not a bug on their side.

The usual causes are dull. Your 200 was lost on the way back. Your handler took eleven seconds and the gateway gave up at ten, having already triggered your work. The gateway retries on any non-2xx and something briefly returned a 502.

None of this is avoidable. The handler has to be safe to run twice.

The four rules

Table of four rules for building an idempotent webhook handler
The four rules that make a handler safe.

Verify the signature first

Before parsing, before logging, before anything. Compute the HMAC over the raw body with your webhook secret and compare it, using a constant-time comparison.

Raw body means raw — the exact bytes received. If your framework has already parsed and re-serialised the JSON, the signature will not match and you will be tempted to skip verification. Do not; find the raw body instead.

This endpoint is public and anyone can POST to it. Without verification, the subscription activation is available to anybody who guesses the URL.

Record the event id, with a unique constraint

Every gateway sends an event or payment identifier that is stable across retries. Store it in a table with a unique index.

The unique constraint is what actually makes this work. Not a check — a constraint. A read-then-write is a race: two retries arriving concurrently both read “not seen”, both proceed, and both activate.

Let the database refuse the second insert. That is a guarantee; an if is a hope.

Work and insert in one transaction

Open a transaction, insert the event id, do the activation, commit.

If the insert fails on the unique constraint, roll back and return success — this one is already done.

The order matters. Activating first and recording afterwards leaves a window where a crash between the two means the next retry activates again.

Return 200 for a duplicate

A duplicate is not an error. The gateway already did its job; telling it something went wrong makes it retry more.

Return 200 with a body saying it was already processed, and log it at info. A log line that says “duplicate webhook, ignored” is how you find out your retry rate is unusually high.

Return quickly, then do the work

Gateways time out in a few seconds. If activation involves sending an email, generating a PDF or calling another service, you will exceed it, and the timeout causes the retry you are trying to survive.

Verify, record, return 200. Queue everything else.

The queued job then needs its own idempotency, because jobs are also retried — key it on the same payment id.

Reconcile against the gateway

Webhooks get lost. Not often, but a dropped callback means a customer who paid and did not get what they paid for, and they will contact you before you notice.

A daily job that fetches the day’s payments from the gateway’s API and checks each one was processed closes that gap. Anything missing gets activated then.

Make that job use the same handler path, so it is covered by the same idempotency. Two code paths that both activate subscriptions is how you get the double-activation you spent all this effort preventing.

Store what the gateway charged

Alongside your own figure. If they ever differ, the gateway is right about what left the customer’s account and your record is wrong.

Finding that from a mismatch alert is much better than finding it from a customer, and the two numbers should never be assumed equal just because the code that computed them looked correct.

Testing it

Send the same payload twice. The second must return 200, change nothing, and appear in the log as a duplicate.

Then send two concurrently — this is the one that finds the race. A handler that passes sequential duplicates and fails concurrent ones is extremely common, and it is exactly the case a gateway retry storm produces.

Then send one with a bad signature and confirm it is rejected before anything else happens.

Three tests, and they cover every way this goes wrong in production.