Idempotent, Webhook-Driven Billing: How to Never Double-Charge
Build idempotent, webhook-driven billing: idempotency keys, deduplicated events, order state machines and reconciliation, with Postgres and Node code.
Full-Stack, Mobile & AI Engineer
The payment succeeded, the webhook arrived twice, and the customer got two memberships, or worse, two charges. Or the user's phone lost signal right after paying, the app never told your server, and the customer was charged for a booking that doesn't exist. Every billing system meets these situations eventually; the question is whether it was designed for them.
Idempotent, webhook-driven billing is the pattern that makes payments boring: every operation can safely happen twice, and the payment provider's verified events, not the app, decide what's true. This guide explains the pattern and how to implement it with any provider: Stripe, Razorpay, KNET gateways or app-store subscriptions.
Key takeaways
- Idempotent means doing something twice has the same effect as doing it once. Billing must be idempotent everywhere.
- Send an idempotency key with every request that creates a charge, so a retry can't create a second one.
- Treat webhooks as the source of truth, verify their signatures, and store each event ID so duplicates are ignored.
- Model orders as a state machine that only moves forward through allowed transitions.
- Reconcile daily against the provider's records, because no event system is perfect.
Why apps double-charge or lose payments
| Situation | What goes wrong without idempotency |
|---|---|
| The app retries a request after a timeout | Two charges for one order |
| A webhook is delivered twice | Two memberships, two shipments, two emails |
| Webhooks arrive out of order | "Refunded" overwritten by a late "succeeded" |
| The user closes the app after paying | The order stays unpaid although money moved |
| Two servers process the same event at once | Race conditions in fulfilment |
Payment providers deliver webhooks "at least once", not "exactly once". Your system has to supply the "exactly once" part.
Building block 1: idempotency keys on outgoing requests
When your server asks a provider to create a charge or a refund, attach a unique key per logical operation. Stripe, for example, accepts an Idempotency-Key header: retrying the same request with the same key returns the original result instead of creating a second charge.
await stripe.refunds.create(
{ payment_intent: order.paymentIntentId, amount: refundAmount },
{ idempotencyKey: `refund-${order.id}-${refundRequest.id}` }
);Base the key on your own IDs (the order and the refund request), so the same intent always produces the same key.
Building block 2: deduplicated webhook processing
Store every processed event ID with a unique constraint, and make "record the event" and "apply its effect" one transaction:
CREATE TABLE processed_events (
provider text NOT NULL,
event_id text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);// Inside one database transaction:
const inserted = await tx.query(
`INSERT INTO processed_events (provider, event_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING RETURNING event_id`,
["stripe", event.id]
);
if (inserted.rowCount === 0) return; // already processed: do nothing
await applyEvent(tx, event); // update the order, grant access, queue emailsIf two copies of the event arrive at once, the primary key guarantees only one transaction applies it.
Building block 3: verify, acknowledge fast, process safely
- Verify the signature with the provider's secret before trusting anything in the payload. With Stripe, that's
stripe.webhooks.constructEvent(rawBody, signature, endpointSecret), which needs the raw request body. - Respond with a 2xx quickly. Providers retry slow or failed deliveries, which creates more duplicates. Do heavy work (emails, PDFs, third-party calls) in a background job.
- When in doubt, fetch the current state of the payment from the provider's API instead of relying on the event's snapshot, especially when events can arrive out of order.
Building block 4: an order state machine
Give each order a status and allow only forward transitions:
| From | Allowed to |
|---|---|
pending | paid, failed, cancelled |
paid | refunded, partially_refunded, fulfilled |
failed | pending (a new attempt) |
refunded | (final) |
Write updates as conditional statements, for example "set status to paid where id = X and status = pending". If a late or duplicate event tries an invalid transition, the update changes nothing, and you log it instead of corrupting the order.
From the field: I've integrated Stripe, Razorpay, RevenueCat, in-app purchases and KNET across 12+ apps, every time with idempotent, webhook-driven billing. On Floey, the same pattern carried KNET, Apple Pay, Google Pay and card payments plus automatic monthly membership renewals. The code differs per provider; the rules don't.
Building block 5: reconciliation
Even with all of the above, webhooks get missed: an endpoint was down, a deploy broke verification, a provider had an incident. A daily reconciliation job closes the gap:
- List the provider's successful payments and refunds for the last few days.
- Compare them with your orders.
- Fix or flag mismatches: paid with the provider but pending with you, refunded there but active here.
- Alert a human when anything can't be fixed automatically.
Testing webhook idempotency
Don't wait for production to find out whether your handlers are safe. Test webhook idempotency deliberately:
- Replay events. Most providers let you resend an event from their dashboard or CLI. Send the same event twice and confirm nothing changes the second time.
- Send events concurrently. Fire two identical deliveries at the same moment and check that only one is applied.
- Send events out of order. Deliver a refund before the payment success and confirm your state machine doesn't end up "paid".
- Crash mid-processing. Stop the worker halfway through and confirm a retry completes the work without duplicating it.
The app's role
The mobile app starts payments and shows status, but it never declares an order paid. After checkout, it asks your server for the order's status and shows "confirming payment" while it's pending. That single rule removes a whole class of bugs where a successful screen and a failed payment disagree.
FAQ
What does idempotent mean in payments?
An idempotent operation produces the same result whether it runs once or several times. In payments, it means a retried charge request, or a webhook delivered twice, never creates a second charge, membership or shipment. It's achieved with idempotency keys, deduplicated events and state checks.
How do I handle duplicate webhooks?
Store each event's ID in a table with a unique constraint, and record it in the same database transaction that applies the event's effect. If the insert finds the ID already exists, skip the event. Providers deliver at least once, so duplicates are normal.
Should I trust the payment result from the mobile app?
No. Treat it as a signal to check with your server. Confirm payments from verified provider webhooks or by querying the provider's API on your server, and let the app display what the server reports.
What is payment reconciliation?
It's a regular comparison between your records and the payment provider's records, to find payments or refunds your system missed. A daily job that compares recent transactions and fixes or flags mismatches catches problems that webhooks alone can miss.
Next steps
If your billing has ever double-charged a customer or lost a payment, I can review it and put idempotency, webhook handling and reconciliation in place. Get in touch, or see how I work as a full-stack developer.
Sources
- Idempotent requests, Stripe
- Receive Stripe events in your webhook endpoint, Stripe
- Webhooks, Razorpay docs
- INSERT ... ON CONFLICT, PostgreSQL docs
Related
In-App Purchases in React Native: RevenueCat vs Going Direct
React Native in-app purchases in 2026: RevenueCat vs expo-iap and react-native-iap, server validation, Android acknowledgement and when each choice wins.
When Apple Requires In-App Purchase, and When You Can Use Stripe
Apple's in-app purchase rules in plain English: what must use IAP, the exceptions (physical goods, services, reader apps, US links) and what Apple charges.
RevenueCat with Expo: Paywalls, Entitlements and Webhooks
A practical RevenueCat guide for Expo apps: setup, offerings, purchases, entitlements, paywalls, restores, backend webhooks, and RevenueCat vs Stripe.
