DEV Community

ZeeshanAli-0704
ZeeshanAli-0704

Posted on

Idempotency Keys - The Double-Charge Trap

The Problem

You click Pay. The server charges your card, but the response is lost on the way back (network drop, timeout, tab freeze). The UI thinks it failed, so you click Pay again and get charged twice.

A timeout means "I don't know what happened", not "payment failed".

The Idea

Idempotent = doing the same thing twice has the same effect as doing it once.

Action Repeat it twice Idempotent?
Set notifications to OFF Still OFF Yes
Add USD 50 to the balance USD 100 added No
Create a payment Two charges No, unless we add a key

An idempotency key is a unique ID for one payment attempt. The UI sends it with the request. If the same key arrives again, the backend says "I've already handled this one" and returns the saved result instead of charging again.

  • UI's job: create the key once, keep it, and resend the same key on every retry.
  • Backend's job: remember each key and its result, so repeats are not executed again.

Full Flow Diagram

Example: user pays USD 50 for order O100.

sequenceDiagram
    actor User
    participant UI as Browser UI
    participant SS as sessionStorage
    participant API as Backend
    participant DB as Database
    participant PSP as Payment Provider

    User->>UI: Click "Pay"
    UI->>SS: Key for O100?
    SS-->>UI: None
    UI->>UI: key = crypto.randomUUID() → K1
    UI->>SS: Save payment-key:O100 = K1
    UI->>UI: Disable Pay button, show "Processing..."

    UI->>API: POST /orders/O100/pay<br/>Idempotency-Key: K1
    API->>DB: Insert (user, K1, O100, processing)
    DB-->>API: Inserted (K1 is new)
    API->>PSP: Charge USD 50 (provider key derived from K1)
    PSP-->>API: Success, payment P900
    API->>DB: Update K1 → succeeded, result P900
    API--xUI: 200 OK (response LOST)

    UI->>UI: Timeout → show "Checking payment status..."
    UI->>SS: Read key for O100
    SS-->>UI: K1 (same key)
    UI->>API: POST /orders/O100/pay<br/>Idempotency-Key: K1 (retry)
    API->>DB: Find K1
    DB-->>API: K1 = succeeded, P900
    Note over API,PSP: Provider NOT called again
    API-->>UI: 200 OK, payment P900 (saved result)
    UI->>SS: Remove payment-key:O100
    UI-->>User: Show receipt ✅ (charged once)

Step-by-Step

Step 1: User clicks "Pay" — UI gets a key

The UI first checks whether this order already has a key (from an earlier attempt or before a refresh).

  • Key found → reuse it. This is a retry of the same payment.
  • No key → generate a new one with crypto.randomUUID().
payment-key:O100  →  K1   (e.g. "6f13d95a-e6c1-4bf2-9459-99b502098dc1")
Enter fullscreen mode Exit fullscreen mode

Use crypto.randomUUID() — it is random and practically never collides. Do not use Date.now(), Math.random(), or the amount; two different payments could produce the same value.

Step 2: Save the key BEFORE sending the request

sessionStorage.setItem("payment-key:O100", key);
Enter fullscreen mode Exit fullscreen mode

Save first, then send. If the page crashes or reloads mid-request, the key is still there and the next attempt reuses it.

Where to store on the UI:

Place Survives refresh? Use it?
Variable / React state No Only for the current screen
sessionStorage Yes (same tab) Recommended — cleared when the tab closes
localStorage Yes (even after browser restart) Avoid — leftover keys on shared devices, shared across tabs
Cookie Yes Avoid — sent with every request for no reason

Also disable the Pay button while the request is in flight. This stops accidental double clicks, but it is only a UX helper; the key is the real protection.

Step 3: Send the payment request with the key

POST /orders/O100/pay
Idempotency-Key: K1
Cookie: session=...   (user is logged in)
Enter fullscreen mode Exit fullscreen mode

The UI sends only the order ID and key. The backend loads the amount from the order itself; never trust a price sent by the browser.

Step 4: Backend checks the key

Why does the backend store the key? Because the UI can't know whether the first request was charged — only the backend can remember.

The backend saves one row per key:

User Key Order Status Result
U1 K1 O100 processing → succeeded P900

When a request arrives:

Backend finds... Backend does
K1 not found New payment: save processing, charge, save result, respond
K1 = processing Another request is already charging → reply "in progress, check again" (e.g. 409)
K1 = succeeded / declined Return the saved result. Do not charge again
K1 exists but for a different order Reject (e.g. 422) — key misuse

The insert must be atomic (a unique constraint on user + key). If two identical requests arrive at the same moment, only one insert succeeds, so only one charge happens.

Step 5: Backend charges and saves the result

The backend calls the payment provider and passes its own provider idempotency key derived from K1, saved before the call. If the backend crashes after the provider charged but before saving the result, a retry reuses that provider key, so the provider returns the original charge instead of creating a new one.

Then it updates the row to succeeded with result P900 and responds.

Step 6: UI handles the response

What the UI gets What it means UI action Key?
200 success Paid Show receipt Remove key
Card declined (clear failure) Not charged Show error, let user try again Remove key; next try gets a new one
409 in progress Still charging Show "Processing...", check again shortly Keep
Timeout / network error / 5xx Unknown! Show "Checking payment status...", retry Keep

The key rule: only remove the key when you know the final outcome.

Step 7: Retry after timeout

The UI retries with the same K1 (a few times, with increasing delays like 1s, 2s, 4s).

Two possible cases, both safe:

  • First request never reached the backend → K1 is new → backend charges once now.
  • First request was already charged → K1 = succeeded → backend returns P900, no second charge.

That's the whole point: the UI doesn't need to know which case happened.

Step 8: User refreshes the page

flowchart TD
    A[Page loads / user clicks Pay] --> B{Key for O100 in sessionStorage?}
    B -- Yes --> C[Reuse the same key]
    B -- No --> D[Generate new key with crypto.randomUUID]
    D --> E[Save key in sessionStorage]
    C --> F[POST /orders/O100/pay with key]
    E --> F
    F --> G{Response}
    G -- Success --> H[Show receipt, remove key]
    G -- Declined --> I[Show error, remove key]
    G -- In progress / timeout / 5xx --> J[Show 'Checking...', keep key, retry later]
    J --> F

After refresh, the UI finds K1 in sessionStorage and sends the request with K1 again. The backend returns the saved result or "in progress" — never a second charge.


UI Code Example

Illustrative only; status codes depend on your API.

function getOrCreateKey(orderId) {
  const name = `payment-key:${orderId}`;
  let key = sessionStorage.getItem(name);
  if (!key) {
    key = crypto.randomUUID();
    sessionStorage.setItem(name, key); // save BEFORE sending
  }
  return key;
}

async function pay(orderId) {
  const key = getOrCreateKey(orderId);

  try {
    const res = await fetch(`/orders/${orderId}/pay`, {
      method: "POST",
      headers: { "Idempotency-Key": key },
      credentials: "include",
      signal: AbortSignal.timeout(15000),
    });

    if (res.ok) {
      sessionStorage.removeItem(`payment-key:${orderId}`);
      return { status: "paid", payment: await res.json() };
    }
    if (res.status === 402) {
      // Clear decline: not charged, a new attempt may use a new key
      sessionStorage.removeItem(`payment-key:${orderId}`);
      return { status: "declined" };
    }
    // 409 in progress, 5xx, etc.: outcome unknown, keep the key
    return { status: "unknown" };
  } catch {
    // Timeout or network error: outcome unknown, keep the key
    return { status: "unknown" };
  }
}
Enter fullscreen mode Exit fullscreen mode

On "unknown", show "Checking payment status..." and call pay(orderId) again after a delay — it automatically reuses the same key.

sessionStorage can throw (e.g. storage full or blocked). In that case, keep the key in memory for this page and avoid letting the user start a fresh payment while one is unresolved.


When Is a New Key Needed?

Situation Same key or new?
Timeout, network error, server error Same
Page refresh during payment Same (read from sessionStorage)
User double-clicks Pay Same
Payment succeeded No more payment needed
Card clearly declined, user tries again New
User changes amount / card / order New (old attempt must be finished first)
A different order New

Simple rule: same intention → same key. New intention → new key.


Security Checklist

  • Store only the key in sessionStorage. Never store card number, CVV, or tokens.
  • The key is not a password. The backend must still check that the logged-in user owns order O100.
  • The backend scopes keys per user, so one user's key can't read another user's payment.
  • Never trust amount or status from the browser; the backend decides both.
  • Clear payment keys on logout.

What If sessionStorage Is Lost?

New tab, cleared storage, or another device → the UI generates a new key for the same order. The idempotency key can't catch this, so the backend needs one more rule:

An order that is already paid, or has a payment in progress, cannot start another charge.

Keys stop duplicate requests. Order rules stop duplicate payments for the same order. You need both.


System Design: Key Creation, Change Detection, and Cancellation

So far, one key = one payment. Real checkouts are messier: the user changes the cart, applies a coupon, or switches cards while a payment is unclear. This section designs a setup that answers three questions:

  1. When should the UI create a key?
  2. How do UI and backend detect that the payment details changed?
  3. How is an old attempt safely cancelled so a new key can be used?

Core idea: a key belongs to a checkout snapshot

A key represents "pay for exactly this checkout". The checkout is identified by a small snapshot:

Snapshot field Changes when...
orderId A different order
orderVersion Backend bumps it on any change to items, quantity, coupon, address, or total
paymentMethodId User picks another saved card or method (an ID like pm_123, never the card number)
Same snapshot      → same intention → reuse the key
Different snapshot → new intention  → old attempt must be finished/cancelled, then a new key
Enter fullscreen mode Exit fullscreen mode

orderVersion is the important part: the UI doesn't have to compare cart items itself. The backend increases the version whenever anything affecting the price changes.

Architecture

flowchart LR
    subgraph Browser
        UI[Checkout UI]
        KM[Key Manager]
        SS[(sessionStorage)]
        UI --> KM
        KM <--> SS
    end

    KM -->|Pay / Cancel + Idempotency-Key| GW[API Gateway<br/>auth, rate limit]
    GW --> PS[Payment Service<br/>idempotency check]
    GW --> OS[Order Service<br/>cart, orderVersion, lock]
    PS <--> IDB[(Idempotency Store<br/>key, fingerprint, status, result)]
    PS <--> OS
    PS -->|charge with provider key| PSP[Payment Provider]
    PSP -->|webhook: final result| WH[Webhook Handler]
    WH --> PS
    RJ[Reconciliation Job] -->|resolve stuck attempts| PS
    CJ[Cleanup Job] -->|expire old finished keys| IDB
Component Responsibility
Key Manager (UI) Creates, reuses, stores, and clears keys based on the snapshot
API Gateway Authenticates the user, rate-limits Pay clicks
Order Service Owns the cart and orderVersion; blocks cart edits while a payment is active
Payment Service Checks the key, compares fingerprints, charges, saves results, handles cancel
Idempotency Store Database table with a unique constraint; the source of truth. A cache like Redis can speed up lookups but must not be the only store
Webhook Handler Receives the provider's final result if the original response was lost
Reconciliation Job Periodically asks the provider about attempts stuck in processing
Cleanup Job Deletes finished keys after the retry window (e.g. 24h). Never deletes processing ones

Data model

Idempotency store (one row per key):

Column Example Purpose
user_id + key U1 + K1 Unique together, so duplicates can't both insert
order_id O100 Which order this attempt pays
fingerprint hash of O100, v3, 5000, USD, pm_123 Detects the same key reused with different details
status processing, succeeded, declined, failed, cancelled Current state
response { paymentId: "P900" } Saved result to replay
provider_key derived from K1, saved before charging Prevents duplicate charges at the provider
expires_at now + 24h Cleanup time (only once finished)

Order (relevant columns):

Column Example Purpose
version 3 Increases on every cart/price change
status unpaid / paid Paid orders can't be charged again
active_attempt K1 or empty Only one payment may be in progress per order

The backend calculates the fingerprint from its own data (loaded order + amount), not from values the browser claims.

Attempt lifecycle

stateDiagram-v2
    [*] --> processing: Pay request accepted (new key)
    [*] --> cancelled: Cancel arrives before Pay
    processing --> succeeded: Provider approved
    processing --> declined: Provider declined
    processing --> processing: Provider timeout, waiting for webhook / reconciliation
    processing --> failed: Reconciliation found no charge
    succeeded --> [*]
    declined --> [*]
    failed --> [*]
    cancelled --> [*]
  • Cancel is only possible before charging starts. Once processing, the UI must wait for the final result.
  • A succeeded payment cannot be "cancelled". Undoing it is a refund, a separate operation with its own key.

When should the UI create a key?

Moment Create a key? Why
Checkout page loads No User hasn't decided to pay; details may still change
User edits cart / coupon / address No Nothing to pay yet
User clicks Pay, no saved attempt Yes Intention is now fixed. Bind the key to the current snapshot
User clicks Pay again, same snapshot No, reuse Same intention (retry, double click, refresh)
Snapshot changed while an attempt is unresolved Not yet Cancel/resolve the old attempt first
Old attempt declined, failed, or cancelled Yes, on next Pay Previous intention is finished, nothing was charged
Old attempt succeeded No Order is paid. Show the receipt

Rule: create the key at the moment of commitment (the Pay click), never earlier.

Backend rules on POST /orders/O100/pay

The request now carries the snapshot:

POST /orders/O100/pay
Idempotency-Key: K1
Content-Type: application/json

{ "orderVersion": 3, "paymentMethodId": "pm_123" }
Enter fullscreen mode Exit fullscreen mode

The backend checks in this order:

  1. Authenticate and confirm the user owns O100.
  2. Look up the key. If it already exists:
    • Fingerprint differs → 422 KEY_REUSED_WITH_DIFFERENT_DETAILS. Nothing is charged.
    • succeeded / declined / failed → replay the saved result.
    • processing → 409 IN_PROGRESS.
    • cancelled → 409 ATTEMPT_CANCELLED. This blocks a late, delayed request from charging after the user cancelled.
  3. New key. In one database transaction:
    • Request orderVersion ≠ current version → 409 ORDER_CHANGED (UI must refresh the checkout).
    • Order is paid or already has an active_attempt → 409 PAYMENT_ALREADY_ACTIVE.
    • Otherwise insert the key as processing and set order.active_attempt = K1.
  4. Charge using the saved provider key, then save the result:
    • Success → key succeeded, order paid.
    • Decline → key declined, clear active_attempt.
    • Timeout → stays processing; webhook or reconciliation finishes it later.

Key lookup comes before the version check, so a retry of an already-paid attempt still gets its saved receipt.

Cart edits while a payment is active

The Order Service rejects cart changes while active_attempt is set:

PATCH /orders/O100/cart  →  409 PAYMENT_IN_PROGRESS
Enter fullscreen mode Exit fullscreen mode

The UI also disables editing during payment. The backend check is what actually guarantees it (another tab or device could try to edit).

Cancel endpoint

POST /orders/O100/payment-attempts/cancel
Content-Type: application/json

{ "key": "K1" }
Enter fullscreen mode Exit fullscreen mode

The key goes in the body, not the URL, so it doesn't end up in access logs or browser history.

Backend finds K1... Backend does Response
Not found (Pay never arrived) Inserts K1 as cancelled (a "tombstone") cancelled
processing Cannot cancel, a charge may be happening processing — wait
declined / failed / cancelled Nothing to do Same status
succeeded Cannot cancel succeeded — show receipt or offer refund

Why insert a tombstone for an unknown key? Because the original Pay request might be stuck in the network and arrive later. The tombstone uses the same unique (user_id, key) slot, so whichever arrives first wins, and the late Pay is rejected instead of charging.

Scenario: user changes the cart after a timeout

sequenceDiagram
    actor User
    participant UI as Checkout UI
    participant SS as sessionStorage
    participant API as Backend

    User->>UI: Click Pay (cart v3, card pm_123)
    UI->>SS: Save {key: K1, snapshot: "v3:pm_123"}
    UI->>API: POST /pay, key K1, v3
    API--xUI: Timeout (outcome unknown)
    UI-->>User: "Checking payment status..."

    User->>UI: "I want to change my cart"
    UI->>API: Cancel K1
    alt K1 never reached backend
        API->>API: Insert K1 as cancelled (tombstone)
        API-->>UI: cancelled
        UI->>SS: Remove K1
        UI-->>User: Cart unlocked
        User->>UI: Edit cart → backend bumps to v4
        User->>UI: Click Pay
        UI->>SS: Save {key: K2, snapshot: "v4:pm_123"}
        UI->>API: POST /pay, key K2, v4
        API-->>UI: Success ✅
        Note over API: If delayed K1 arrives now → cancelled, no charge
    else K1 is processing
        API-->>UI: processing
        UI-->>User: "Payment in progress, please wait"
    else K1 already succeeded
        API-->>UI: succeeded, P900
        UI->>SS: Remove K1
        UI-->>User: Show receipt (refund if they still want changes)
    end

Scenario: backend crashes mid-payment (K1 stuck in processing)

The key reached the backend and was saved as processing, but the status was never updated. Now the user retries with K1. What happens?

Why K1 gets stuck:

1. UI sends Pay with K1
2. Backend saves K1 = processing (+ provider key PK1)   ✅ saved
3. Backend calls the provider
4. One of these happens:
   a) Backend crashes before or during the provider call  → maybe NOT charged
   b) Provider charged, backend crashed before saving      → charged
   c) Provider is slow or timed out                       → unknown
5. K1 is still "processing" in the DB
Enter fullscreen mode Exit fullscreen mode

The backend itself doesn't know whether money moved.

What the retry does:

UI      -> Backend: Pay O100 with K1 (retry)
Backend -> DB:      Find K1
DB      -> Backend: K1 = processing
Backend -> UI:      409 "Payment in progress"   (provider NOT called)
UI:     shows "Checking payment status...", asks again in a few seconds
Enter fullscreen mode Exit fullscreen mode

Rule: a retry request never starts a charge for a key that is already processing. It could be case (b) or (c), where money already moved or is moving.

Who fixes the stuck status?

  1. Provider webhook (usually first). When the provider finishes the charge, it notifies the backend's webhook handler, which updates K1 to succeeded or declined.
  2. Reconciliation job (safety net). Every minute or so, it finds attempts stuck in processing for longer than a threshold (e.g. 2 minutes) and asks the provider about them using PK1, which was saved with K1 before calling the provider.
Provider says (for PK1) Reconciliation job does
Charge succeeded K1 → succeeded, save P900, order → paid
Charge declined K1 → declined, unlock the order
No charge found (crashed before calling) Retry the charge with the same PK1, or mark K1 failed (not charged) and unlock the order

Retrying with the same PK1 is safe: if the charge already exists, the provider returns it instead of creating a second one.

If the provider can't be reached, or offers no idempotency or status lookup, K1 stays processing and the order stays locked. It is flagged for manual review, never charged blindly.

sequenceDiagram
    participant UI as Checkout UI
    participant API as Backend
    participant DB as Database
    participant PSP as Payment Provider
    participant RJ as Webhook / Reconciliation

    UI->>API: Pay with K1
    API->>DB: K1 = processing, save PK1
    API->>PSP: Charge with PK1
    PSP->>PSP: Charge succeeds
    Note over API: 💥 Backend crashes. K1 still processing
    API--xUI: No response

    UI->>API: Retry with K1
    API->>DB: Find K1 → processing
    API-->>UI: 409 In progress (no charge)
    UI->>UI: "Checking payment status..."

    RJ->>PSP: Status of PK1?
    PSP-->>RJ: Succeeded, P900
    RJ->>DB: K1 = succeeded, P900, order paid

    UI->>API: Retry with K1
    API->>DB: Find K1 → succeeded
    API-->>UI: 200 Paid, P900 (saved result)
    UI->>UI: Show receipt, remove K1

Timeline:

12:00:00  UI: Pay with K1
12:00:00  Backend: K1 = processing, PK1 saved
12:00:01  Backend calls provider with PK1 → provider charges
12:00:01  Backend CRASHES (K1 still processing)
12:00:15  UI timeout → "Checking payment status..."
12:00:20  UI retries K1 → 409 in progress (no charge)
12:00:30  UI retries K1 → 409 in progress (no charge)
12:00:40  Webhook arrives: PK1 succeeded → K1 = succeeded, P900
12:00:45  UI retries K1 → 200 "Paid, P900" (saved result)
          UI shows receipt, removes K1 from sessionStorage
          Customer charged ONCE ✅
Enter fullscreen mode Exit fullscreen mode

If the webhook never arrives, the reconciliation job performs the same update around 12:02.

What the user sees:

  • While processing: "Payment in progress, please don't close this page." Pay button and cart stay locked.
  • After a long wait (e.g. 1-2 minutes): "We're confirming your payment. We'll email you when it's done." The UI can stop polling; the backend still finishes the work.
  • On refresh: the UI reads K1 from sessionStorage and asks again, getting the latest status.
  • If the attempt ends as failed (not charged), the backend replays it like a decline (e.g. 402 in the UI example), so the UI removes K1 and the next Pay creates a new key.

Why it's safe:

Protection Prevents
K1 saved as processing before charging A retry starting a second charge
PK1 saved before calling the provider The provider creating two charges
Webhook + reconciliation job K1 staying stuck forever
Order lock (active_attempt) A new key starting another payment for the same order

Key Manager code (UI)

Illustrative; extends the earlier example with a snapshot and cancel.

const nameFor = (orderId) => `payment-attempt:${orderId}`;
const snapshotOf = (c) => `${c.orderVersion}:${c.paymentMethodId}`;

// Called only when the user clicks Pay
function getOrCreateAttempt(checkout) {
  const name = nameFor(checkout.orderId);
  const saved = JSON.parse(sessionStorage.getItem(name) ?? "null");
  const snapshot = snapshotOf(checkout);

  if (saved && saved.snapshot === snapshot) {
    return saved; // same intention: reuse key
  }
  if (saved) {
    // Details changed but the old attempt is unresolved
    throw new Error("Resolve the previous payment attempt first");
  }

  const attempt = { key: crypto.randomUUID(), snapshot };
  sessionStorage.setItem(name, JSON.stringify(attempt)); // save BEFORE sending
  return attempt;
}

// Called when the user wants to change details during an unclear payment
async function cancelAttempt(orderId) {
  const saved = JSON.parse(sessionStorage.getItem(nameFor(orderId)) ?? "null");
  if (!saved) return { status: "none" };

  try {
    const res = await fetch(`/orders/${orderId}/payment-attempts/cancel`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ key: saved.key }),
    });
    if (!res.ok) return { status: "unknown" }; // keep key, try later

    const { status } = await res.json();
    if (["cancelled", "declined", "failed", "succeeded"].includes(status)) {
      sessionStorage.removeItem(nameFor(orderId)); // final: key no longer needed
    }
    return { status }; // "processing" → keep key and wait
  } catch {
    return { status: "unknown" }; // network error: keep key
  }
}
Enter fullscreen mode Exit fullscreen mode

UI reactions:

  • cancelled / declined → unlock the cart; the next Pay creates a new key.
  • processing / unknown → keep the cart locked, show "Payment in progress".
  • succeeded → show the receipt; offer a refund if the user still wants changes.
  • Pay returns 409 ORDER_CHANGED → the order changed elsewhere (another tab). Reload the checkout, show the new total, and let the user confirm. The old key was never accepted, so remove it; the confirmation Pay creates a new key.

Design summary

Concern Handled by
Create key at the right time UI Key Manager, on Pay click only
Reuse on retry / refresh UI snapshot match + sessionStorage
Detect changed details with the same key Backend fingerprint check (422)
Detect stale checkout orderVersion check (409 ORDER_CHANGED)
Prevent edits during payment Order lock (active_attempt)
Cancel before charging Cancel endpoint + tombstone
Late request after cancel Tombstone blocks it
Lost response / crash Saved result, provider key, webhook, reconciliation
Key stuck in processing Retries get 409, reconciliation resolves via provider key
Storage growth Cleanup job removes finished keys after the retry window

Summary

Click Pay
  → UI: reuse key, or create one with crypto.randomUUID()
  → UI: save key in sessionStorage
  → UI: POST with Idempotency-Key
  → Backend: new key? charge and save result. Known key? return saved result
  → UI: success/decline → remove key | unknown → keep key and retry
Enter fullscreen mode Exit fullscreen mode

More Details:

Get all articles related to system design
Hashtag: SystemDesignWithZeeshanAli

systemdesignwithzeeshanali

Git: https://github.com/ZeeshanAli-0704/front-end-system-design

⬆ Back to Top

Top comments (0)