Most code that fails tells you it failed. Payment code doesn’t. The charge succeeds, the response never reaches you, and your service is holding a timeout for money that has already left the customer’s account. Nothing in the logs looks wrong. The state is wrong.
Write every payment path so that running it twice costs the customer once. Five things do that: an idempotency key scoped to an intent, amounts held as integer minor units, an append-only ledger, a replay-safe webhook handler, a nightly reconciliation against the provider.
Why does the same payment charge twice?
A payment charges twice because the network can fail after the charge has succeeded. Your code sees a timeout, the provider sees a completed charge, and a retry without an idempotency key creates a second one. The user retries too: back, refresh, another click on a dead-looking button.
Webhooks arrive twice and out of order. Stripe doesn’t guarantee delivery order. The example on its own How events work page is a new subscription emitting customer.subscription.created, invoice.created, invoice.paid and charge.created, and invoice.paid can reach you first. Stripe retries with exponential backoff for up to three days in live mode. Separately, an endpoint that stays unresponsive or keeps erroring for several days is disabled altogether, and delivery stops. Two different failures. Conflate them and you wait on retries that have already ended. What you miss you recover by listing events from the API, and that archive goes back thirty days.
Three recovery windows for one event, none of them the same length. Workbench keeps summaries for thirteen months and the Dashboard’s Resend button works for fifteen days; stripe events resend on the CLI goes back thirty days.
Where the idempotency key lives in a payment flow
An idempotency key lives at three boundaries: the client’s call into your API, your own call out to Stripe, the webhook handler keyed on the event id. Two of those are unique indexes you own. Stripe owns the third — its idempotent requests reference says the layer “compares incoming parameters to those of the original request and errors if they’re not the same”, and a 409 is a conflict with another request, potentially one using the same key.
An idempotency key identifies an intent, not an attempt. Generate it inside the retry loop and you have written a slower way of charging twice. Scope it to an authorised attempt rather than to the order, because a decline is terminal and should release the intent for another card.
ts
const key = `order:${orderId}:charge:${attemptToken}`;
const claim = await db.query(
`INSERT INTO payment_attempts (key, order_id, amount_minor) VALUES ($1,$2,$3)
ON CONFLICT (key) DO NOTHING RETURNING id`,
[key, orderId, amountMinor],
);
if (claim.rowCount === 0) {
const done = await readRecordedResult(key);
if (done) return done;
// Claimed, never recorded: it died mid-flight. The same key makes
// Stripe replay its answer, or 409 if it is still running.
}
const intent = await stripe.paymentIntents.create(
{ amount: amountMinor, currency: "gbp", payment_method: methodId, confirm: true },
{ idempotencyKey: key },
);
await recordResult(key, intent); The row and the provider call can never be atomic, so rows left claimed but unrecorded are normal. A sweeper asks the provider what became of them, and a replayed response carries the header Idempotent-Replayed: true, so it can tell a replay from a fresh charge. Stripe keeps a key for at least 24 hours and can drop it after that. A floor, not a window. Your row outlives the key for as long as the charge is disputable, because otherwise a double charge surfaces days later with nothing to say which one the customer agreed to.
Use integer minor units, not floating-point money
Store amounts as integer minor units with the currency beside them: 1999 and GBP, never 19.99. In IEEE 754 doubles 0.1 + 0.2 is 0.30000000000000004 and 19.99 * 100 is 1998.9999999999998, so truncating loses a penny. The double nearest 1.15 sits just below it, so (1.15).toFixed(1) returns "1.1" — wrong on some prices, right on others, which is why these bugs survive testing.
A hard-coded * 100 is right for sterling and dollars, wrong for yen, which has no minor unit, and wrong again for the three-decimal Kuwaiti dinar. Take the exponent from ISO 4217, then check your provider against it. Stripe’s supported currencies page says ISK “transitioned to a zero-decimal currency, but backward compatibility requires you to represent it as a two-decimal value”, and UGX carries the same note; HUF and TWD are paid out as though they had no minor unit at all.
Why an append-only ledger beats a balance column
An append-only ledger beats a balance column because a balance gives you the right number and no account of how it got there. UPDATE accounts SET balance = balance - 1999 WHERE id = 42 is correct and useless. Write entries instead: debits positive, credits negative, every transaction summing to zero, enforced by a deferred constraint trigger at commit.
Ferrystone is not a client and never was. It’s an invented payments ledger this site uses whenever retries or idempotency come up, and every figure below is picked so you can recheck the arithmetic rather than measured from anybody’s system. Ferrystone takes 4,000 card payments a month at £19.99.
Stripe’s UK pricing page lists 1.5% + 20p for standard UK cards. So 1.5% of 1999 pence is 29.985, which rounds to 30. 30 plus 20 is 50. And 1999 − 50 leaves 1949 in the Stripe balance.
One £19.99 charge in Ferrystone’s ledger, in pence:
| txn | account | amount_minor | balance_txn |
|---|---|---|---|
| t_901 | cash:stripe | 1949 | txn_3Q… |
| t_901 | expense:fees | 50 | txn_3Q… |
| t_901 | revenue:orders | -1999 | txn_3Q… |
1949 + 50 − 1999 = 0, the invariant the trigger checks. In production you read fee and net off the balance transaction instead, because the provider rounds its own way and prices per card type. The payout to the bank, days later, is its own transaction from cash:stripe to cash:bank, which makes reconciliation three-way.
Now the part that costs us work. Take a few hundred payments a month and never issue a partial refund, and the ledger is the one item on this page I would let you defer: an unreconciled ledger is a worse record than the provider’s dashboard. Defer nothing else here.
Writing a webhook handler that is safe to replay
A webhook handler is safe to replay when running it a hundred times on one event leaves the same state as running it once. Verify the signature, insert the raw event keyed on its id, return 200, and process in a worker. Delivery is at-least-once whatever you return, so that insert does the real work.
I don’t know what Stripe’s handler timeout actually is. The 20-second and 30-second figures that circulate are not in the documentation. The webhooks page gives only the symptom, a delivery marked (Timed out) ERR, and an instruction to return a 2xx “before any complex logic that could cause a timeout”.
Set state rather than apply deltas: “active until 30 November” survives a replay, “add 30 days” doesn’t. Replay-safe is not order-safe, so order by something you can trust. Stripe records created on snapshot events in whole seconds and tells you not to sequence on it, because two events on one charge can share a timestamp: “Don’t use created to determine event order or whether you’ve already processed an event. Track event IDs to identify duplicate deliveries instead.” Re-read the object from the API when an event arrives and write back the state it reports, or keep a monotonic version per object and drop anything that fails to advance it. A late success then can’t reopen a refunded charge.
How to reconcile a payments ledger against Stripe nightly
Reconcile nightly by listing /v1/balance_transactions filtered on created[gte], paging to the end, then comparing amount, fee, net and currency against your own rows. Compare the money, not the reference alone. Mismatches go to an exceptions table.
Overlap the previous run by the full retry window rather than by a day. Ferrystone’s numbers show why. 4,000 charges a month is about 133 a day; add refunds and a daily payout and call it 140 balance transactions. A webhook can land up to three days late, so a ledger row can be written three days after Stripe created the matching balance transaction, which means a job comparing yesterday’s created range against rows written yesterday mismatches on every late delivery. That is what a one-day window misses. Three days of overlap is 140 × 3 = 420 rows, and limit caps a page at 100, so 420 / 100 = 4.2: five requests a night.
Derate that volume estimate by ten and the answer holds. At 1,400 transactions a day, three days is 4,200 rows and 42 pages. Neither number argues for the shorter window.
Alert on any rows in the exceptions table at all. Without a baseline of zero you can’t tell a benign timing difference from a real bug.
What a payments audit trail needs to survive a dispute
A payments audit trail survives a dispute when it records what the customer actually saw at the moment of consent: the actor and a UTC timestamp; the amount and currency as displayed; the terms and price-list version in force; the provider’s ids; the IP address and user agent. Boring records win disputes. A dashboard screenshot and a plausible story lose them.
Revoke UPDATE, DELETE and TRUNCATE from the application role, and let a separate migration role own the table. Revoking from the owner is theatre, and PostgreSQL’s privileges page says why: “An object’s owner can choose to revoke their own ordinary privileges, for example to make a table read-only for themselves as well as others. But owners are always treated as holding all grant options, so they can always re-grant their own privileges.” The right to drop or alter a table is worse than reversible. It “cannot be granted or revoked in itself”, which is the real argument for parking ownership in a role your application never authenticates as.
IP addresses are personal data, so a retention job expires whole rows by age once the payment is no longer disputable. That job is the only process allowed to delete.
Retrofitting payment code costs more than building it
Retrofitting costs more because the engineering is identical and the history is gone. Before the first charge, all of this is a unique index, an integer column, a ledger table, a worker, a cron entry. A day’s work.
A year into live traffic it is a migration, a backfill out of whatever history the provider still exposes (thirty days of full payloads, as above), and a reconciliation that can never close the months where nothing recorded what the customer saw. Every step of it runs against a system taking live money, so the schema change competes with the writes it is trying to describe. The engineering hasn’t got harder. The hole in the record is permanent.