WritingPayments

An idempotency key without a request fingerprint hides a wrong charge

Store a fingerprint of amount, currency and payment method beside each idempotency key, so a reused key with a changed request fails loudly, not silently.

A checkout creates a payment of £42.50 for order ord_8841, and the client derives its idempotency key from the order id: pay-ord_8841. The customer goes back, adds an item, and the basket is now £57.90. The client posts the payment again with the new amount and the same key, because the order id has not changed. The server finds the key, returns the stored 201 from the first attempt, and the client reads status: authorised and shows "Paid £57.90". The card was authorised for £42.50, nothing logged above info, no alert fired, and the gap surfaces weeks later in a settlement report.

The rule I apply is this: an idempotency key is only safe when the server stores a fingerprint of the fields that define the charge beside it, and rejects a repeat whose fingerprint differs instead of serving the stored response. The key answers "have I seen this request before"; the fingerprint answers "is this the same request", and the second question is the one that protects the money.

What goes into the fingerprint

The fingerprint is a hash over the fields a gateway would treat as a different charge: the amount in minor units (4250, never 42.50), the currency code in upper case, the payment method reference (a stored card token or a wallet token id), the capture mode (automatic or manual) and the tenant id. Leave out anything a client may legitimately change between retries: free-text descriptions, metadata, the request timestamp and tracing headers. Canonicalise before hashing (sorted keys, integers for money, no whitespace), because two clients serialising the same charge differently must arrive at the same hash. I have built routing that puts 700+ payment channels behind one API, and this check runs before any request reaches a gateway.

Store the row before you call the gateway, not after. The row holds the key, the fingerprint, a state (pending or completed) and, once known, the response status and body. A unique constraint on (tenant id, key) is what turns the insert into the lock.

flowchart TD
  A[Request arrives] --> B{Key exists}
  B -- no --> C[Insert key as pending]
  C --> G[Call gateway]
  G --> S[Store response]
  B -- yes --> F{Fingerprint matches}
  F -- no --> X[Reject with conflict]:::accent
  F -- yes --> P{Still pending}
  P -- yes --> W[Return in progress]
  P -- no --> R[Return stored response]
Three outcomes for an incoming key, decided before any gateway call
TypeScript
// Simplified: the idempotency guard that runs before any gateway call.
type Stored = {
  fingerprint: string;
  state: 'pending' | 'completed';
  response?: { status: number; body: string };
};

function fingerprint(req: ChargeRequest): string {
  const canonical = JSON.stringify({
    amountMinor: req.amountMinor,           // 4250, never 42.5
    currency: req.currency.toUpperCase(),   // GBP
    methodToken: req.methodToken,           // card or wallet token id
    capture: req.capture,                   // automatic or manual
    tenantId: req.tenantId,
  });
  return sha256(canonical);
}

async function guard(key: string, req: ChargeRequest): Promise<Stored | 'proceed'> {
  const fp = fingerprint(req);
  const fresh = { fingerprint: fp, state: 'pending' as const };
  if (await store.insertIfAbsent(req.tenantId, key, fresh)) return 'proceed';

  const found = await store.get(req.tenantId, key);
  if (found.fingerprint !== fp) throw new Conflict('key reused for another charge');
  if (found.state === 'pending') throw new InProgress(2); // Retry-After seconds
  return found; // same request again: replay the stored response
}

Why a mismatch must be an error

Return 409 Conflict with a body that names the difference: idempotency key reused with a different amount. A client that reuses keys across basket changes is already the client that does not compare the response amount to what it sent, so a quiet success never gets noticed. An error, by contrast, reaches the customer as a failed payment on screen, reaches on-call as a rise in a status code that should sit near zero, and reaches the client's owner as a bug with a reproducible cause. Never fall through to the gateway on a mismatch either: that authorises a second charge under a key the client believes is one payment.

The same stored row covers the failure that a key without state leaves open. Two browser tabs post pay-ord_8841 30 ms apart. Without a pending row, neither finds the key, both call the gateway, and the card carries two £42.50 holds: the customer sees one payment in your system and one extra hold on their statement for several days. With the insert as the lock, the second tab's insert fails, it receives 409 with Retry-After: 2, retries, and gets the stored 201 from the first tab. The gateway was called once.

Retention follows from the same row. Keep it at least as long as a client could plausibly retry, and I would not go below 24 hours: a key that expires while a mobile app is still retrying in the background produces exactly the double authorisation the key exists to prevent. Beyond that, keeping keys for the life of the payment record costs little, and it means a replay from a stale device a week later is still answered with the original response or a conflict rather than a fresh charge.

Start on the server: add the fingerprint and state columns to the idempotency table, make the insert the first statement in the handler, and return a conflict on mismatch. Then fix the clients so the key is minted per attempt (a fresh UUID stored with the order and replaced whenever the basket changes), which keeps the conflict path rare and the alert on it worth answering.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing