Payments· 5 min read

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.

By

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

SituationWhat goes wrong without idempotency
The app retries a request after a timeoutTwo charges for one order
A webhook is delivered twiceTwo memberships, two shipments, two emails
Webhooks arrive out of order"Refunded" overwritten by a late "succeeded"
The user closes the app after payingThe order stays unpaid although money moved
Two servers process the same event at onceRace 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 emails

If 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:

FromAllowed to
pendingpaid, failed, cancelled
paidrefunded, partially_refunded, fulfilled
failedpending (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:

  1. List the provider's successful payments and refunds for the last few days.
  2. Compare them with your orders.
  3. Fix or flag mismatches: paid with the provider but pending with you, refunded there but active here.
  4. 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

Bhuwanesh Sisodia

Bhuwanesh Sisodia

Full-Stack, Mobile & AI Engineer

Bhuwanesh Sisodia is a Full-Stack, Mobile & AI Engineer based in Mumbai, with 7+ years of experience and 50+ apps shipped to the App Store and Google Play. He works independently with startups and product teams worldwide.

About meGitHubLinkedInX

Related