So you added payments to your API. It works. The test card goes through. Postman is green. You are feeling like a fintech engineer. 😎
Then one evening the network gets slow.
Your server calls the payment provider. The provider charges the card. The answer is on its way back... and your request times out first.
Your code sees an error. So it does the "responsible" thing and retries.
The customer gets two debit alerts. 😭
And the customer is not sending you a nice message.
If you have ever stood at a POS stand in Lagos, you already know this story. You want to withdraw ₦5,000. The agent slots your card. The machine thinks... and thinks... "Transaction timed out". The agent says "Oga, let's try again". Machine prints a slip. Then your phone buzzes. Debit alert. Buzz again. Debit alert. 👀
The machine did not fail. It was just late with the answer. The second try was a brand new withdrawal.
That is exactly the bug we are fixing today. The fix is a small thing called an idempotency key, and it is one of the most important habits in payment code.
At Prooval I built the payment flows on Stripe, Paystack and Fincra, and making them idempotent was a big part of that work. Today you'll see why.
What does "idempotent" even mean? 🤔
Big word, simple idea: doing it twice has the same effect as doing it once.
- Pressing the lift button 10 times still brings one lift. Idempotent. ✅
- Sending a ₦5,000 transfer 10 times sends ₦50,000. Not idempotent. ❌
Creating a payment is the second kind. So we have to make it safe to retry.
An idempotency key is a unique string your server sends along with the request (usually in an Idempotency-Key header). The provider remembers the key. If the same key arrives again, it doesn't charge again. It just replays the first answer.
Back to the POS stand: imagine every withdrawal had a slip number written down before the agent touched the machine. If the machine hangs, the agent retries with the same slip number, and the bank says "I've seen slip 0042 already, here's what happened." One withdrawal. One debit alert. Peace. 🥰
The trick (and where most people get it wrong) is when you create that key and where you keep it.
Prerequisites
- Node.js 20 or newer (I used Node 22)
- Basic Express and TypeScript
-
curl(or Postman if you prefer clicking)
No real payment account needed. We'll build a tiny fake payment provider that behaves like the real ones on the important part: same key + same body = same answer. I'm being honest here: it is NOT Stripe or Paystack, it's a pretend provider so you can break things safely on your laptop.
Let's Dive Right In 😁😁
Here's the plan:
Customer Our Checkout API Fake Provider | | | |-- POST /pay ---->| | | |-- POST /v1/charges ------->| charges card 💸 | | Idempotency-Key: K1 | ...answer is slow 🐢 | | (times out after 1s) | | | | | |-- retry, SAME key K1 ----->| "seen K1 before" | |<-- 201 (replayed) ---------| no new charge ✅ |<-- paid ---------| | Set up the project:
mkdir idempotency-keys-demo && cd idempotency-keys-demo npm init -y npm install express@5 npm install -D typescript @types/express @types/node@22 npm pkg set type=module scripts.build="tsc" scripts.start="node dist/server.js" Folder structure:
idempotency-keys-demo/ ├── src/ │ ├── fakeProvider.ts │ ├── paymentClient.ts │ ├── orders.ts │ ├── app.ts │ └── server.ts ├── demo.sh ├── tsconfig.json └── package.json Step 1: Tell TypeScript to be strict — tsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src"] } strict: true because we are touching money. No "any" vibes here. 😅
Step 2: The pretend bank — src/fakeProvider.ts
This file plays the role of the payment provider. It does one thing: create charges, and remember idempotency keys.
import express from "express"; import { createHash, randomUUID } from "node:crypto"; // A tiny pretend payment provider. NOT Stripe, NOT Paystack. // It only copies the idea: same Idempotency-Key + same body = same answer. type Charge = { id: string; orderId: string; amount: number; currency: string }; type SavedResult = { bodyHash: string; status: number; body: unknown }; const charges: Charge[] = []; const idempotencyStore = new Map<string, SavedResult>(); let slowResponsesLeft = 0; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const hashBody = (body: unknown) => createHash("sha256").update(JSON.stringify(body)).digest("hex"); export const provider = express(); provider.use(express.json()); provider.post("/v1/charges", async (req, res) => { const key = req.get("Idempotency-Key"); const bodyHash = hashBody(req.body); if (key) { const saved = idempotencyStore.get(key); if (saved && saved.bodyHash !== bodyHash) { res.status(422).json({ error: "Idempotency-Key was already used with a different body" }); return; } if (saved) { res.set("Idempotent-Replayed", "true").status(saved.status).json(saved.body); return; } } const { orderId, amount, currency } = req.body as Omit<Charge, "id">; const charge: Charge = { id: `ch_${randomUUID().slice(0, 8)}`, orderId, amount, currency }; charges.push(charge); // 💸 money has moved if (key) idempotencyStore.set(key, { bodyHash, status: 201, body: charge }); if (slowResponsesLeft > 0) { slowResponsesLeft--; await sleep(3000); // the "network is bad" part: charged, but the answer is late } res.status(201).json(charge); }); // Test helpers so we can see what happened provider.get("/v1/charges", (_req, res) => { res.json({ count: charges.length, charges }); }); provider.post("/__test/slow", (req, res) => { slowResponsesLeft = Number(req.query.times ?? 1); res.json({ slowResponsesLeft }); }); Three important parts, don't skip them:
- Seen this key + same body? Replay the saved answer and add
Idempotent-Replayed: true. Stripe sends that exact header on replayed responses. - Seen this key + different body? Refuse. Someone is reusing a key for a different payment. Stripe also errors in this case; the IETF draft for the
Idempotency-Keyheader suggests422, so that's what our fake uses. - The slow mode charges first, then sleeps 3 seconds. That is our POS machine: money gone, answer late.
Step 3: The agent who retries — src/paymentClient.ts
This is the code your server uses to call the provider. It has a timeout and it retries. The interesting part is the getKey parameter.
const PROVIDER_URL = process.env.PROVIDER_URL ?? "http://localhost:4000"; const TIMEOUT_MS = 1000; const MAX_ATTEMPTS = 3; export type ChargeParams = { orderId: string; amount: number; currency: string }; export type Charge = ChargeParams & { id: string }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Errors worth retrying: timeouts, network drops, "busy", "slow down", server errors const isRetryableStatus = (status: number) => status === 409 || status === 429 || status >= 500; export async function createCharge( params: ChargeParams, getKey: () => string // where the Idempotency-Key comes from ): Promise<Charge> { let lastError: unknown; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const key = getKey(); try { const res = await fetch(`${PROVIDER_URL}/v1/charges`, { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": key }, body: JSON.stringify(params), signal: AbortSignal.timeout(TIMEOUT_MS), }); if (res.ok) { const replayed = res.headers.get("Idempotent-Replayed") === "true"; console.log(`attempt ${attempt} key=${key.slice(0, 8)} -> ${res.status}${replayed ? " (replayed)" : ""}`); return (await res.json()) as Charge; } if (!isRetryableStatus(res.status)) { throw new Error(`Provider said no: ${res.status} ${await res.text()}`); } lastError = new Error(`Provider returned ${res.status}`); } catch (err) { if (err instanceof Error && err.message.startsWith("Provider said no")) throw err; lastError = err; console.log(`attempt ${attempt} key=${key.slice(0, 8)} -> ${(err as Error).name}`); } await sleep(200 * 2 ** attempt); // 400ms, 800ms, ... } throw lastError; } What's happening:
-
AbortSignal.timeout(1000)gives up after 1 second and throws aTimeoutError. - We retry up to 3 times, waiting a bit longer each time (backoff), so we don't hammer a provider that's already struggling.
- A plain "no" (like a
422) is not retried. Retrying a "no" just gets you another "no". - We call
getKey()on every attempt. Whether that returns a new key or the same key is the whole difference between a happy customer and an angry one. Hold that thought. 👀
Step 4: Write the slip number down first — src/orders.ts
This file does one thing: keep orders. And every order gets its payment key the moment it is created, before anyone tries to pay.
import { randomUUID } from "node:crypto"; export type Order = { id: string; amount: number; // minor units, e.g. kobo currency: string; status: "pending" | "paid"; paymentKey: string; // the Idempotency-Key for paying THIS order chargeId?: string; }; // In-memory "database". In real life this is a table with a unique paymentKey column. const orders = new Map<string, Order>(); export function createOrder(amount: number, currency: string): Order { const order: Order = { id: `ord_${randomUUID().slice(0, 8)}`, amount, currency, status: "pending", paymentKey: randomUUID(), // created and saved BEFORE any payment attempt }; orders.set(order.id, order); return order; } export const findOrder = (id: string) => orders.get(id); export function markPaid(order: Order, chargeId: string) { order.status = "paid"; order.chargeId = chargeId; } That's it. That one paymentKey line is the slip number. 📝
I'm using an in-memory Map so you don't need a database to follow along. In production, save the key in your real database (a column on the order or a payments table). If the key only lives in memory and your server restarts in the middle of a retry, you lose the slip and you're back to square one. Stripe's docs say it plainly: if you retry across separate calls or process restarts, generate and persist a key for the operation and send it on every attempt.
Amounts are in minor units (kobo), because floats and money don't mix. That's a whole story for another day. 😋
Step 5: Two checkout doors, one is broken — src/app.ts
Now the Express app. I'm adding a broken route on purpose so you can see the bug with your own eyes.
import express from "express"; import { randomUUID } from "node:crypto"; import { createCharge } from "./paymentClient.js"; import { createOrder, findOrder, markPaid } from "./orders.js"; export const app = express(); app.use(express.json()); app.post("/orders", (req, res) => { const { amount, currency } = req.body as { amount: number; currency: string }; if (!Number.isInteger(amount) || amount <= 0 || typeof currency !== "string") { res.status(400).json({ error: "amount (integer, minor units) and currency are required" }); return; } res.status(201).json(createOrder(amount, currency)); }); // ❌ The bug: a brand-new key on every attempt app.post("/orders/:id/pay-naive", async (req, res) => { const order = findOrder(req.params.id); if (!order) { res.status(404).json({ error: "Order not found" }); return; } const charge = await createCharge( { orderId: order.id, amount: order.amount, currency: order.currency }, () => randomUUID() ); markPaid(order, charge.id); res.json(order); }); // ✅ The fix: one key per order, saved before the first attempt, reused every time app.post("/orders/:id/pay", async (req, res) => { const order = findOrder(req.params.id); if (!order) { res.status(404).json({ error: "Order not found" }); return; } if (order.status === "paid") { res.json(order); // already paid, nothing to do return; } const charge = await createCharge( { orderId: order.id, amount: order.amount, currency: order.currency }, () => order.paymentKey ); markPaid(order, charge.id); res.json(order); }); app.get("/orders/:id", (req, res) => { const order = findOrder(req.params.id); if (!order) { res.status(404).json({ error: "Order not found" }); return; } res.json(order); }); app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); res.status(502).json({ error: "Payment status unknown. Do not charge again; check with the provider." }); }); Spot the difference between the two pay routes:
-
pay-naivepasses() => randomUUID(). New slip number on every retry. Looks safe. Is not. 🙈 -
paypasses() => order.paymentKey. Same slip number every time. -
payalso checksstatus === "paid"first, so a double-click on the Pay button doesn't even reach the provider. - The error handler says "status unknown" on purpose. After a timeout you genuinely don't know if money moved, so never tell the customer "failed, try again with a new payment".
(We return paymentKey in the JSON only so the demo can show it. In a real app, keep it on the server.)
Step 6: Start both servers — src/server.ts
import { app } from "./app.js"; import { provider } from "./fakeProvider.js"; provider.listen(4000, () => console.Source:https://dev.to/fredabod/stop-charging-customers-twice-idempotency-keys-in-express-for-beginners-10gp Related posts
![Embedded Software Engineering — Ch.10: OTA Firmware Update [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6nak2soahu0o1ww35hy.png)
Embedded Software Engineering — Ch.10: OTA Firmware Update [EN]
![Embedded Software Engineering — Ch.11: Embedded Security [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8lj1zci0ucpfvn91aqd5.png)
Embedded Software Engineering — Ch.11: Embedded Security [EN]

Signing What Was in the Context Window: draft-durga-wimse-ctx-isolation and Its Self-Reported Taint

DCHP: One CBOR Request for mdoc and SD-JWT VC, In Person and Online
![Embedded Software Engineering — Ch.12: Embedded Case Studies [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth5r2cczjsot6ae55lq5.png)
