Build · Advanced

Missed payment webhooks: the payment succeeded, the shop never heard

A customer pays, the provider confirms, and the notification to your shop is lost, late or delivered twice. This is how to design order processing so that missed payment webhooks cost nothing: no unpaid-looking paid orders, no duplicate orders, no second charge.

6 min read

A hand holding a payment card over a card terminal

Photo: public domain (CC0)

In this guide

Where a payment notification can go wrong

A card or wallet payment involves three parties that do not share a database: the customer’s browser, the payment provider and your shop. The provider learns the result first. Your shop learns it in two ways: the customer’s browser is redirected back to a return page, and the provider sends a server-to-server notification, usually called a webhook.

Neither channel is reliable alone. The customer can close the tab before the redirect. The webhook can hit your server during a deployment, a timeout or a database outage. Because providers retry notifications that were not acknowledged, the same event can also arrive twice, or arrive hours later and after a newer event for the same payment.

The practical consequence: webhook delivery is “at least once, in no guaranteed order”. A shop that assumes “exactly once, in order” will sooner or later show a paid order as unpaid or ship twice. If you are planning a payment integration, design for that from the first day.

Failure cases for payment webhooks, what you see and what prevents the damage
Failure case Symptom in the shop Safeguard
Notification never arrives Order stays “awaiting payment” although money was taken Reconciliation job that asks the provider
Notification arrives twice Two confirmation emails, stock reduced twice Unique event ID and a conditional status change
Notification arrives late Order was cancelled as unpaid, then payment shows up Check provider state before cancelling, handle “paid after cancel”
Events arrive out of order A refund is processed before the payment is known State machine that rejects impossible transitions and re-fetches state
Handler crashes after acknowledging Event is gone, nothing was processed Store the event durably before answering
Forged notification Order marked paid without any payment Signature verification on the raw request body
Shop retries its own request after a timeout Customer is charged or refunded twice Idempotency key on every outgoing request

Durable processing: store first, acknowledge fast, work from a queue

The endpoint that receives webhooks should do very little. Its whole job is four steps:

  1. Read the raw request body and verify the signature.
  2. Insert the event into an events table, with the provider’s event ID under a unique constraint.
  3. Answer with a 2xx status as soon as the insert has committed.
  4. Leave everything else to a worker that reads unprocessed events.

If you acknowledge before the event is stored and the process dies, the provider believes you have it and never sends it again. If you do the real work (mark the order paid, write the invoice, call the warehouse) before acknowledging, a slow dependency makes the request time out, the provider retries, and you process the same payment in parallel.

If the database is down, answer with a 5xx status so the provider retries later. Never answer 2xx for an event you could not store.

The worker takes events from the table or from a queue fed by it. Queues also deliver at least once, so the worker must be safe to run twice on the same event. After a fixed number of failed attempts, move the event to a dead-letter state and alert a person.

Idempotency: making “mark order paid” safe to repeat

Idempotent means that running an operation twice has the same effect as running it once. Three techniques together achieve it.

Deduplicate by event ID

The unique constraint on the provider’s event ID stops the same delivery from being stored twice. It does not help when two different events describe the same payment.

Change state conditionally

Do not write “set status to paid”. Write the transition: set status to paid where the order ID matches and the status is still “awaiting payment”. The database reports how many rows changed. Only when exactly one row changed do the side effects run: reduce stock, create the invoice, queue the confirmation email. A second run changes zero rows and does nothing.

Record the side effects in the same transaction as the status change, as rows in an outbox table that a sender works through. Otherwise a crash between “paid” and “send email” leaves an order that is paid and never confirmed.

Use idempotency keys on outgoing requests

The same problem exists in the other direction. When your shop asks the provider to capture or refund a payment and the response times out, you do not know whether it happened. Most payment APIs accept an idempotency key: a unique value you generate once per intended operation and send again unchanged on every retry, so the provider performs it once. Store the key with the order before the first attempt, not in memory.

Before trusting any notification, also compare its amount and currency with the order. A valid event for the wrong amount is not a paid order.

Reconciliation: asking instead of waiting

Providers stop retrying after some time, and an endpoint that was misconfigured for a day will have missed everything. Reconciliation is the safety net: a scheduled job that asks the provider for the truth.

  • Open orders: for every order that has been “awaiting payment” for longer than a few minutes, query the provider by the stored payment reference and feed the answer through the same handler the webhook uses.
  • Reverse direction: once a day, list the provider’s successful payments for the period and check that each one matches an order in a paid state with the same amount.
  • Before cancelling: an order is cancelled as unpaid only after the provider has confirmed that no payment succeeded or is pending.

If reconciliation and the webhook worker both call the same conditional transition, it does not matter which of them arrives first.

The figures below are an illustrative example, not measurements: a shop with 10,000 successful payments in a month, of which 150 (1.5%) did not arrive as one clean notification on the first attempt. 96 + 42 + 12 = 150.

Illustrative example: 150 of 10,000 payments with an imperfect notification

With deduplication and reconciliation, all 150 end in the same state as the other 9,850.

A recovery design without duplicate orders or charges

Duplicate orders usually have one cause: the order is created in two places, once on the return page and once by the webhook. The cure is to create it once, before the payment.

  • Create the order as “awaiting payment” before redirecting to the provider, and send its ID to the provider as the payment’s reference
  • Treat the return page as a hint: it triggers a status query, it never marks an order paid from URL parameters
  • Verify signatures on the raw body and keep secrets out of the code repository
  • Store every event under a unique event ID before acknowledging it
  • Make every status change a conditional transition and run side effects only when a row changed
  • Send an idempotency key with every capture, refund and payment creation
  • Run reconciliation for open orders every few minutes and a full comparison daily
  • Alert a person on dead-lettered events and on payments without a matching order

A payment that arrives for an order already cancelled needs a business rule, not only code: reopen the order if the stock is still there, otherwise refund automatically and tell the customer. Decide it in advance. For an existing online shop, the quickest audit is to replay a stored webhook twice in a test environment and see what happens. The same patterns apply to any API integration that receives events from another system.

Questions we hear often

No. The customer may never reach it, and its URL parameters can be altered. Use it to trigger a server-side status query and to show the result, and let the webhook or reconciliation job make the order paid.

A 2xx status. The provider only needs to know that you have the event. Answering with an error would cause further retries of something you already hold.

For open orders, every few minutes is common, starting a few minutes after the order was created so normal notifications arrive first. The full comparison of provider payments against orders can run once a day.

Want this sorted without doing it yourself?

Tell us what you are working on. A person replies within one working day.