RU Send your project
All articles

Payments without double charges: webhooks, signatures and reconciliation

How to handle payment webhooks so retries, races and missed events don't turn into double charges and lost orders.

· 4 min readCodeInfrastructure

In a prototype, payment works like this: the user clicks Pay, comes back to a thank-you page, and that page grants access. In production this breaks three ways: the user closes the tab before returning, clicks the button twice, or opens the thank-you page by hand. Reliable payments rest on a different principle. The source of truth isn’t the browser, it’s the payment provider, and you learn everything from it through webhooks.

The thank-you page grants nothing

Rule one: the return page only displays status. Access, credits and shipping are granted by the webhook handler on the server. If the page loads before the webhook arrives, it says “payment processing” and polls the server rather than deciding anything itself.

That removes a whole class of problems: the closed tab, the forged return URL, and the user who noticed ?status=success can be typed by hand.

Verify the signature against the raw body

A webhook is an ordinary POST to a public URL. Anyone can send one, so every request is checked against a signature the provider computes with a shared secret.

A common mistake: the framework parses the JSON before the verification code sees the body. Re-serialized JSON differs from the original in whitespace and key order, the signature fails, and a frustrated developer disables the check. The signature is computed over the raw request bytes, so turn off body parsing for that route.

The second detail is time. Signatures usually include a timestamp, and requests older than a few minutes should be rejected. Otherwise an intercepted webhook can be replayed.

Retries and ordering

Providers deliver webhooks at least once. The same event can arrive twice, and events can arrive out of order: “subscription renewed” before “subscription created”.

Two rules follow:

  • Processing is idempotent. Recording the event and applying its effects happen in one transaction. If the event was already recorded, the handler does nothing and returns success.
  • Don’t build state from the sequence of events. When an event arrives, fetch the object’s current state from the provider’s API and bring your record in line with it. Delivery order then stops mattering.
insert into payment_events (provider_event_id, received_at)
values ($1, now())
on conflict (provider_event_id) do nothing
returning id;
-- no row returned: already processed, stop here

And respond quickly. If the handler does heavy work like generating documents or sending email, queue the event and acknowledge right away. Otherwise the provider times out, marks the delivery as failed and sends it again.

A double click isn’t a webhook problem

Double charges are most often born not in webhooks but when the payment is created. The user clicks twice, the network retries a request, and the server creates two payments.

A disabled button helps but doesn’t protect you. An idempotency key does: the server creates the order once and sends the request to the provider with a key tied to that order. A retry with the same key returns the payment that already exists instead of making a new one. Most payment APIs support this.

Reconciliation

Even a correct handler will eventually miss an event: a deploy, a database outage, an expired certificate. So you need reconciliation, a scheduled job that pulls the provider’s list of payments for a period and compares it with your database.

What it looks for:

  • paid at the provider, not granted on your side;
  • granted on your side, but no payment exists or it was refunded;
  • amounts or currencies that don’t match.

Each mismatch goes to a human, not to an automatic fix. The causes vary, and half the time they’re worth understanding rather than papering over.

Don’t forget refunds and disputes either. If those events never reach your handler, access stays with someone who already got their money back.

SUBXCAT helps set up payment handling so your accounting and your database agree without manual corrections.

Want us to do this for you?

Tell our assistant about your project — a rough estimate in five minutes, a review in 48 hours.

Book an intro call 20 minutes: an engineer, a designer and a marketer look at your project with you.
Book a call