When I was wiring the payment flow into JetztMeal, I spent two days reading Stripe's documentation more carefully than I'd ever read any API docs. The feature I kept coming back to wasn't webhooks, wasn't the dashboard, wasn't the fraud tooling. It was a single HTTP header: Idempotency-Key.
Most developers know what idempotency means in theory. Fewer have thought carefully about what the failure modes look like when you get it wrong in a payments context — or what a correct implementation actually requires on the server side.
The problem idempotency keys solve
Networks fail. Users click twice. Mobile apps retry on timeout. These are not edge cases — they're the normal operating conditions of any application that does meaningful work over HTTP.
Without idempotency, every retry is a potential duplicate action. In a payments context, a duplicate action is a duplicate charge. The user's card gets billed twice. They notice on their statement four days later. They open a dispute. You lose the dispute fee. None of this generates an error — the second charge succeeded, which is why logs won't help you find it.
An idempotency key ties a request to a specific intended action. If the same key arrives twice, the server recognises the duplicate and returns the result of the first execution — without executing the action again.
How Stripe implements it
Stripe's implementation is worth studying because it handles the hard edge cases explicitly. When you send a request with an idempotency key:
If the first request is still in flight, the second request with the same key gets a 409 response. Stripe doesn't queue it — it rejects it cleanly so your client knows to back off.
If the first request completed, any subsequent request with the same key returns the original response, byte-for-byte. Not a new response that looks similar — the cached original.
If the first request failed, the key is still consumed. If you want to retry a failed payment, you need a new idempotency key — because you might be retrying with different parameters, and the server can't assume the intent is the same.
import Stripe from "stripe"; import { randomUUID } from "crypto"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function createCharge( orderId: string, amount: number, currency: string, paymentMethodId: string, ) { // Key is deterministic: same order always generates same key const idempotencyKey = `charge-${orderId}`; return stripe.paymentIntents.create( { amount, currency, payment_method: paymentMethodId, confirm: true, return_url: `https://app.example.com/orders/${orderId}`, }, { idempotencyKey } ); }
Building idempotency on your own endpoints
The Stripe header is only half the picture. Your own API endpoints — order creation, subscription provisioning, webhook processing — need the same guarantee. This is where most backends get it wrong.
A correct server-side idempotency implementation needs four things:
Persistent storage for keys. Not in-memory — in the database. If your server restarts, in-memory keys vanish and you lose the deduplication guarantee exactly when you need it (during a recovery).
Atomic check-and-insert. The lookup and the insertion of a new key need to happen in the same transaction, or you have a race condition where two concurrent requests with the same key both pass the lookup check and both execute.
Response caching, not re-execution. On a duplicate, you return the cached response — not the result of running the action again with the same inputs. Re-execution is still two executions.
TTL on keys. Idempotency keys can't live forever. 24 hours is Stripe's window; I use the same for most contexts.
export async function withIdempotency<T>( key: string, execute: () => Promise<T>, ): Promise<T> { return db.transaction(async (tx) => { // Atomic: insert or return existing const existing = await tx .select() .from(idempotencyKeys) .where(eq(idempotencyKeys.key, key)) .for("update") .limit(1); if (existing.length > 0) { // Already processed — return cached response return JSON.parse(existing[0].response) as T; } // First time — execute and cache const result = await execute(); await tx.insert(idempotencyKeys).values({ key, response: JSON.stringify(result), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }); return result; }); }
Webhook deduplication is the same problem
Stripe (and every other payment provider) will occasionally deliver the same webhook event more than once. This is documented, expected, and your handler needs to handle it gracefully.
The solution is identical: store processed event IDs in the database and check before processing. The only difference is that the key is the event ID provided by Stripe, not one you generate.
In JetztMeal, every webhook handler wraps its logic in a withIdempotency call keyed on the Stripe event ID. If the same event arrives twice — which happens maybe 2-3 times per thousand events in practice — the second handler returns immediately without touching the database.
The deeper point
Idempotency is one of those primitives that feels like defensive programming until you experience a duplicate charge in production. Then it becomes the first thing you add to any endpoint that touches money, inventory, or anything that can't easily be undone.
The beautiful thing about it is that the implementation is not complicated. It's just disciplined. A unique key, an atomic check, a cached response. Three things. The reason most backends don't have it is not that it's hard — it's that nobody added it on day one, and retrofitting it feels risky. Add it on day one.