Engineering

Building payments that never double-charge

Payments look like one API call and turn out to be a distributed-systems problem. Idempotency keys, webhooks as the source of truth, ledger-based wallets, and the reconciliation that decides whether users trust you with their money.

ARAbishek Reddy
Abishek Reddy, Founder
Jun 20267 min read

Payments are the feature founders most underestimate and engineers most respect. On the surface it’s a single API call: take the money, return success. Underneath, you’re coordinating three systems that can each fail on their own — your app, the payment gateway, and the user’s bank — across a network that drops messages at the worst possible moment. Get it slightly wrong and you don’t get a bug report. You get a furious user who was charged twice, or an order that took someone’s money and shipped nothing.

We’ve built payments for coin wallets, subscriptions, and checkout flows on Razorpay and UPI. The code that takes the happy-path payment is maybe a day’s work. The rest of the week goes to the question that actually matters: what happens when the network lies to you?

The core problem: you never really know what happened

Here’s the situation that defines payments engineering. A user taps Pay. Your server calls the gateway. The charge succeeds — and then the response times out on the way back. Your server has no idea the money moved. The user, staring at a spinner, taps Pay again.

Done naively, you’ve just charged them twice. The fix is an idempotency key: a unique token the client generates once per payment attempt and sends with every retry. The server records it, and any repeat carrying the same key returns the original result instead of charging again. It’s a small idea that quietly prevents the single worst thing a payment system can do.

In payments, a retry isn’t an edge case. It’s the normal case — and the system has to be built assuming every request will be sent more than once.

The webhook is the truth, not the response

It’s tempting to treat the gateway’s immediate response as final — green tick, mark the order paid, move on. That’s a trap. The synchronous response can be lost, and some payment methods don’t resolve in the moment at all: a UPI collect request or a bank redirect is approved by the user in another app, and the result lands seconds or minutes later.

So we treat the webhook — the gateway calling our server to say “this payment succeeded” — as the source of truth, not the client. The app shows an optimistic “confirming…” state, but money only moves in our database when the webhook arrives, is verified by its signature so nobody can forge a paid event, and is itself made idempotent, because gateways will cheerfully deliver the same webhook more than once.

A wallet is a ledger, not a number

For apps with a coin or credit balance — common in dating and live-streaming products — the instinct is to store one balance integer and add to it and subtract from it. Don’t. The moment two actions touch the balance at once, or a refund lands mid-transaction, that number drifts, and you have no way to prove what it should have been.

We model the wallet as an append-only ledger instead: every credit and debit is a row that’s never edited, and the balance is the sum of those rows. It’s more work up front, but the balance is always explainable — you can point at the exact transactions that produced it — and concurrent changes can’t silently corrupt it. When money is involved, “I can prove why” beats “it’s probably right.”

Reconciliation: assume you’ll drift anyway

Even with all of that, your records and the gateway’s records will occasionally disagree — a webhook that never arrived, a refund issued from the gateway’s dashboard but never reflected in your app. So we run a reconciliation job: a scheduled task that pulls the gateway’s settled transactions and compares them against our ledger, flagging anything that doesn’t line up.

It’s unglamorous, and it’s the thing that lets you sleep. The goal isn’t to never drift; it’s to catch drift automatically, before a user does, with a record clear enough to fix it without guessing.

What this discipline buys you

  • No double charges. Idempotency keys make retries safe, so you can retry aggressively instead of leaving payments stuck in limbo.
  • No phantom orders. Money moves on a verified webhook, so a dropped response never leaves a paid user empty-handed or an unpaid order shipped.
  • A balance you can defend. A ledger means every number traces back to transactions — essential the first time a user disputes their wallet.
  • Problems you find first. Reconciliation surfaces mismatches on a schedule, not in a support ticket.

None of this is exotic. It’s the ordinary discipline of treating payments as the distributed-systems problem they are, rather than a single function call. The difference shows up exactly where it counts — in whether people trust you enough to hand over their money a second time.

Building something like this?

That's the work we do every day. Tell us what you're shipping.

Start a project