How to Handle Payment Webhooks Properly
Verify, acknowledge, queue. Three steps in that order, and almost every webhook bug you will ever have is caused by doing them in a different one.
Webhooks are where payment integrations go wrong, and they go wrong the same way every time. Here is the order that prevents it.
The three steps, and the order is the lesson
- Verify. Check the signature against the raw request body before anything else.
- Acknowledge. Return 200 immediately.
- Work. Do the real processing asynchronously.
Almost every webhook bug is caused by doing these in a different order.
Step 1: verify, on the raw bytes
Capture the body before any middleware parses it. If you parse to JSON and re-serialise, whitespace and key order change and the signature no longer matches. Frameworks that helpfully parse the body for you are the usual culprit.
Compare with a constant-time function, not ==. Then, and only then, parse.
Step 2: acknowledge fast
This is the step people skip, and it causes the failure everyone blames on the platform.
If your handler updates the order, sends an email, calls a warehouse API and generates a PDF before returning, it may take eight seconds. The platform times out at five, marks it failed, and retries. Now you have two deliveries doing the same work, and a queue of retries behind them.
Return 200 as soon as the signature checks out. Then queue.
Step 3: work asynchronously and idempotently
Push a job. In the worker, act on the payment identifier rather than on the payload's numbers: look the payment up through the API and use what the platform says its state is.
Make the work idempotent by keying on the payment identifier. The same callback will arrive twice at some point, and the second one must find the order already fulfilled and do nothing.
The cases worth testing
- Bad signature. Must be rejected without side effects.
- Duplicate delivery. Must be a no-op.
- Out-of-order arrival. A later state arriving before an earlier one. Do not move an order backwards.
- Unknown payment. Log and investigate rather than crashing the worker.
- Slow downstream. The acknowledgement should not depend on it.
Reconciliation, because webhooks are not a guarantee
Networks lose things. Servers restart mid-request. Run a reconciliation job that periodically lists recent payments and compares them to your orders, and treat webhooks as the fast path rather than the only path.
Most teams add this after their first missed payment. Adding it on day one costs an hour.
Local development
Point staging at a tunnel to your machine, and keep saved payloads you can replay against the handler in isolation. Being able to fire a duplicate or a bad signature on demand turns the tricky cases into ordinary tests.
Frequently Asked Questions
How should a payment webhook handler be structured?
Verify the signature on the raw body, return 200 immediately, then do the real work asynchronously. Doing the work before acknowledging causes timeouts, which causes retries, which causes duplicate processing.
Why does my webhook keep retrying?
Because your endpoint did not return a success status quickly enough. Most platforms retry on timeout or on any non-2xx response, so slow handlers create a retry storm that looks like a platform problem and is not.
Should the webhook payload be trusted for the payment amount?
No. Verify the signature, then look the payment up by its identifier through the API and act on the platform's state. Treat the callback as a signal that something changed rather than as the source of truth about what it changed to.
How do I test webhooks locally?
Point the platform at a tunnel to your machine while working against staging, and keep a set of saved payloads you can replay to test the handler in isolation. Test the failure cases too: bad signature, duplicate delivery, out-of-order arrival.
