Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 14 min read

Stop Charging Customers Twice - Idempotency Keys in Express for Beginners

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

Stop Charging Customers Twice - Idempotency Keys in Express for Beginners

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.

Shocked

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-Key header suggests 422, 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 a TimeoutError.
  • 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-naive passes () => randomUUID(). New slip number on every retry. Looks safe. Is not. πŸ™ˆ
  • pay passes () => order.paymentKey. Same slip number every time.
  • pay also checks status === "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.log("πŸ’³ Fake provider on http://localhost:4000"));
app.listen(3000, () => console.log("πŸ›’ Checkout API on http://localhost:3000"));

Build and run:

npx tsc --strict
npm start

Step 7: Let's break it on purpose β€” demo.sh

Open a second terminal. This script makes the first provider response slow (charged, but late) and pays an order both ways.

#!/usr/bin/env bash
# Run `npm start` in another terminal first.
API=http://localhost:3000
PROVIDER=http://localhost:4000
new_order() { curl -s -X POST $API/orders -H "Content-Type: application/json" -d '{"amount":500000,"currency":"NGN"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).id'; }

echo "== 1. Naive payment (new key every attempt), slow network on first try =="
ORDER=$(new_order)
curl -s -X POST "$PROVIDER/__test/slow?times=1" > /dev/null
curl -s -X POST $API/orders/$ORDER/pay-naive; echo
echo "Charges for $ORDER:"
curl -s $PROVIDER/v1/charges | node -pe "JSON.parse(require('fs').readFileSync(0)).charges.filter(c => c.orderId === '$ORDER').length"

echo "== 2. Fixed payment (one key per order), same slow network =="
ORDER=$(new_order)
curl -s -X POST "$PROVIDER/__test/slow?times=1" > /dev/null
curl -s -X POST $API/orders/$ORDER/pay; echo
echo "Charges for $ORDER:"
curl -s $PROVIDER/v1/charges | node -pe "JSON.parse(require('fs').readFileSync(0)).charges.filter(c => c.orderId === '$ORDER').length"

echo "== 3. User double-clicks Pay =="
curl -s -X POST $API/orders/$ORDER/pay; echo
echo "Charges for $ORDER:"
curl -s $PROVIDER/v1/charges | node -pe "JSON.parse(require('fs').readFileSync(0)).charges.filter(c => c.orderId === '$ORDER').length"

echo "== 4. Same key, different amount (someone reused a key) =="
KEY=$(curl -s $API/orders/$ORDER | node -pe 'JSON.parse(require("fs").readFileSync(0)).paymentKey')
curl -s -i -X POST $PROVIDER/v1/charges -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -d "{\"orderId\":\"$ORDER\",\"amount\":999,\"currency\":\"NGN\"}" | head -1

echo "== 5. Unknown order =="
curl -s -X POST $API/orders/ord_nope/pay; echo
chmod +x demo.sh
./demo.sh

Here's what I got (your IDs will be different):

== 1. Naive payment (new key every attempt), slow network on first try ==
{"id":"ord_6ef95b4e","amount":500000,"currency":"NGN","status":"paid","paymentKey":"ba51359b-0486-4277-bd83-f47b9cadf40f","chargeId":"ch_4280c8cf"}
Charges for ord_6ef95b4e:
2
== 2. Fixed payment (one key per order), same slow network ==
{"id":"ord_3502f657","amount":500000,"currency":"NGN","status":"paid","paymentKey":"3e82095b-2c0f-4d8c-a3c6-b8838d773137","chargeId":"ch_a5f85f52"}
Charges for ord_3502f657:
1
== 3. User double-clicks Pay ==
{"id":"ord_3502f657","amount":500000,"currency":"NGN","status":"paid","paymentKey":"3e82095b-2c0f-4d8c-a3c6-b8838d773137","chargeId":"ch_a5f85f52"}
Charges for ord_3502f657:
1
== 4. Same key, different amount (someone reused a key) ==
HTTP/1.1 422 Unprocessable Entity
== 5. Unknown order ==
{"error":"Order not found"}

And the server terminal tells the full story:

attempt 1 key=2e67f6ae -> TimeoutError
attempt 2 key=8673a2d8 -> 201
attempt 1 key=3e82095b -> TimeoutError
attempt 2 key=3e82095b -> 201 (replayed)

Look at the naive one. Look at it. πŸ‘€

The API happily says "status":"paid", everything looks fine... and the customer was charged twice for one ₦5,000 order. Two different keys, two charges. The first one timed out on our side, but the provider had already done the work.

The fixed one: same key on both attempts, the provider says "replayed", one charge. πŸŽ‰

celebrate

Bonus test: if the provider is completely down, the fixed route tries 3 times with the same key and then returns 502 with "Payment status unknown". The order stays pending. No guessing, no surprise second charge later.

Before vs after

Before (pay-naive) After (pay)
Key created On every attempt Once, when the order is created
Key stored Nowhere Saved with the order
Slow network + retry Two charges 😭 One charge, second answer replayed
Double-click on Pay Another charge "Already paid", provider not called
Server restart mid-retry New key, new charge Same key (if saved in a real DB)

Why we do it this way 🧐

1. The key belongs to the business action, not the HTTP request.
"Pay for order ord_3502f657" is one action. It might take three HTTP attempts. All three must carry the same key. A new key means you are telling the provider "this is a new payment". And the provider believes you.

2. A timeout is not a failure.
It's an "I don't know". The money may have moved. Stripe's docs say it directly: when a connection fails you might not know whether the request was executed, so retry with the same key and the same parameters.

3. Keys don't live forever.
Stripe keeps keys for at least 24 hours and may remove them after that. So retry within that window. If you still don't know what happened after that, stop retrying (with old or new keys) and go and check: look up the payment, check your dashboard, and let webhooks tell you the final state.

4. Same key, same body. Always.
If you change the amount and reuse the key, Stripe errors instead of charging. That's a feature: it stops one key from being used for two different things.

5. Use random keys, nothing personal.
Stripe suggests V4 UUIDs (or another random string with enough entropy), up to 255 characters, and warns against putting things like email addresses in them. crypto.randomUUID() is built into Node, no package needed.

6. If you use an official SDK, read what it already does.
The Stripe Node SDK retries failed requests once by default (maxNetworkRetries) and adds idempotency keys to those automatic retries. You can also pass your own key as a request option:

await stripe.paymentIntents.create(
  { amount: 500000, currency: "ngn" },
  { idempotencyKey: order.paymentKey }
);

(I tested this snippet with [email protected] pointed at a small local mock server instead of real Stripe, and the mock received our exact key in the Idempotency-Key header. With a real test key it goes to Stripe.) The SDK's built-in key only protects retries inside one call. If your own code retries, or a job re-runs after a crash, you still need your own saved key.

7. Not every provider does it the same way.
Idempotency-Key is the header Stripe uses, and there's an IETF draft trying to standardise it (it lists Stripe, Adyen and Worldpay among the implementers, and notes PayPal uses its own PayPal-Request-Id header). Before you ship, check your provider's docs for their exact header, how long they keep keys, and what they return on a repeat. If your provider has no idempotency support at all, your saved order status + "check before charging again" + webhooks become your safety net.

thinking hard

Recap (say it with me) πŸ—£οΈ

  1. A timeout means "I don't know", not "it failed".
  2. Create the idempotency key once per business action (one order = one key).
  3. Save it before the first attempt.
  4. Reuse it on every retry. Never generate a fresh one inside the retry loop.
  5. Same key always goes with the same body.
  6. Retry with backoff, and only for things worth retrying (timeouts, 409, 429, 5xx).
  7. If you still don't know after the retry window, stop and check. Don't charge again "just in case".

Next time the POS machine hangs, you'll smile, because now you know what the bank should be doing behind the scenes. 😁😁

smug nud

GitHub repo: [https://github.com/FredAbod/stop-charging-customers-twice-idempotency-keys-code]

I hope this was helpful. Don't forget to drop a like and follow πŸ˜‹πŸ˜‹

Thank you for taking the time to read πŸ₯°πŸ₯°

See you on the next one πŸ€—

waving bye

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.