7 min read·Run it here

Stripe: Idempotency Keys

Wrap the Stripe client so a retried create never double-charges. The key is the only cache key, the body travels with it, the window is 24 hours, and every retry reuses the key it already sent.

Pattern
Key -> in-flight promise + body fingerprint + ttl
Time
O(1) per charge, O(live keys) per prune
Space
O(live keys)

Every payments interview reaches the same question: the request timed out, you do not know whether the charge landed, what do you do. The answer is a header. Idempotency-Key on the POST, Stripe stores the response against that key for 24 hours, and the retry gets the stored response instead of a second PaymentIntent. That part is easy. What they are actually screening for is the wrapper you put around it, because every real double charge happens in the wrapper.

The recipe

Say before you type: "The key is the only cache key, I store the in-flight promise rather than the resolved value, and every retry sends the key I already sent."

Three fields.

private entries = new Map<string, { body: string; at: number; pending: Promise<PaymentIntent> }>();
private ttlMs: number;
private client: StripeClient;

Key on the key, and on nothing else

The tempting mistake is a cache keyed on the request body. It looks more careful than keying on the string the caller handed you, and it is a bug with a customer complaint attached.

const entry = this.entries.get(key); // not JSON.stringify(params)

Two charges for { amount: 500, currency: "usd" } with two different keys are two people buying coffee, or one person buying it twice. The key is the caller's assertion that these are the same request. Nothing else in the payload carries that claim, and a body cache silently eats the second sale.

Say the inverse out loud too, because it is the sentence that shows you understand what the key is for: the same key with the same body is one charge no matter how many times it arrives.

Store the promise, not the result

This is the one that separates people who have shipped this from people who have read about it.

const pending = this.client.paymentIntents.create(params, { idempotencyKey: key });
this.entries.set(key, { body, at: nowMs, pending });
return pending;

charge is async, so two callers can be inside it at the same time. Await the create and store the resolved intent afterward and both callers find an empty map, both call create, and the local cache you added to prevent a double charge has produced one. Registering the promise before the first await closes the window completely, and the second caller awaits the same promise the first one is waiting on.

This is also why the server-side key matters even when your local cache is correct. Your map dies with the process. Stripe's copy of the key survives the deploy.

The body travels with the key

Reuse a key with different parameters and Stripe answers idempotency_error, not your charge. Catch that locally so it never leaves the process, and fingerprint the body properly.

private canonical(value: unknown): string {
  if (value === null) return "null";
  if (value === undefined) return "undefined";
  if (Array.isArray(value)) return "[" + value.map((v) => this.canonical(v)).join(",") + "]";
  if (typeof value === "object") {
    const source = value as Record<string, unknown>;
    const keys = Object.keys(source).filter((k) => source[k] !== undefined).sort();
    return "{" + keys.map((k) => JSON.stringify(k) + ":" + this.canonical(source[k])).join(",") + "}";
  }
  return typeof value + ":" + JSON.stringify(value);
}

JSON.stringify alone is not a fingerprint. It walks keys in insertion order, so {amount, currency} and {currency, amount} hash differently and your wrapper rejects a request Stripe would have deduped without comment. Sort the keys at every level, metadata included.

Then stop. Arrays keep their order, because a list of line items is a list and not a set, and sorting the members hides a genuine body change. And put the type in the fingerprint: 2000 and "2000" are different bodies, and a canonicaliser built out of String(v) collapses them.

The window is 24 hours and it is half open

if (nowMs - entry.at >= this.ttlMs) this.entries.delete(key);

Default ttlMs to 24 * 60 * 60 * 1000, and say why ttlMs || 0 is not that default: it prunes every entry the instant it is written and turns dedupe off in a way no test catches. At exactly ttlMs the entry is gone, so the same key past the window is a brand new request, which is correct. Past the window there is nothing left to be idempotent about.

A replay is a read. It does not push at forward. Stripe's 24 hours run from first use, and a sliding window means a hot key never expires, which is the leak the ttl existed to close.

The retry is the whole exercise

for (let attempt = 0; ; attempt += 1) {
  try {
    return await this.client.paymentIntents.create(params, { idempotencyKey: key });
  } catch (error) {
    if (attempt >= BACKOFF_MS.length || !this.isRetryable(error)) throw error;
    const retryAfter = typeof error.retryAfter === "number" ? error.retryAfter : null;
    await this.sleep(retryAfter === null ? BACKOFF_MS[attempt] : retryAfter * 1000);
  }
}

Every attempt sends the same key. A retry that mints a fresh key has not retried anything, it has issued a second charge, and it only does it on the day the API is slow, which is the day you are least able to look at it. Stripe's own SDKs work this way: maxNetworkRetries reuses the key it already sent.

Retry rate_limit_error and api_error. Do not retry card_error or invalid_request_error, because a declined card declines again and a malformed request stays malformed. Retry the error with no type at all, the socket hang up and the ECONNRESET and the timeout, because no type means no response, and no response means the request never got an answer rather than a bad one. Stripe's own client agrees: _shouldRetry retries when there is no response. That case is the reason the whole exercise exists. You do not know whether the write landed, and the key you already sent is what makes asking again safe. Refusing to retry there is the conservative-looking move that leaves a real charge in limbo and a customer with no order.

Two details from the response beat your switch statement. If the response carries a Retry-After, honour it, and remember it is in seconds while everything else in your code is in milliseconds, so it gets a * 1000. And Stripe sends a Stripe-Should-Retry hint on some errors: when it is present it wins in both directions, because the server knows things your type switch does not.

Failure leaves nothing behind

pending.catch(() => {
  const current = this.entries.get(key);
  if (current && current.pending === pending) this.entries.delete(key);
});

Same lesson as the webhook processor, one layer up: a charge that never landed is not a charge. Leave the rejected promise in the map and every future caller with that key inherits the same rejection forever, including the one that would have succeeded. Drop the entry and let the next attempt try again, which is safe precisely because Stripe still has the key.

What the interviewer is listening for

They know the header. What they are checking is whether you know that the local cache and the server-side key are two different defenses with two different lifetimes, and whether you can say the retry rule without hedging: same key, every attempt, or you have built the bug you were hired to prevent. Both are one sentence. Have them ready.