Implementing Idempotency Keys for a Payment API

By James Nguyen Updated September 24, 2026
Implementing Idempotency Keys for a Payment API

A customer got charged twice for the same order because their phone lost signal right as our charge endpoint responded, the client never saw the success response and retried, and our API happily processed the same charge a second time since nothing about the request told it this was a retry rather than a new purchase. Idempotency keys are the standard fix for this, the pattern Stripe and most serious payment APIs use, and implementing it properly involves more edge cases than "check if we've seen this key before."

What an Idempotency Key Actually Promises

The client generates a unique key, typically a UUID, once per logical operation, and sends it in a header on the request. If the same key arrives again, whether because the client didn't see the first response, retried after a timeout, or a proxy duplicated the request, the server must return the exact same result as the first successful attempt rather than performing the operation again. The key is the client's, generated once and reused across retries of the same logical attempt, never regenerated per retry, or the whole mechanism does nothing.

The Data Model: Storing Both the Key and the Response

A naive implementation might just track "have we seen this key" as a boolean, but that only prevents duplicate processing, it doesn't let you return the original result to the retry, which the client genuinely needs, they don't know if their original request succeeded or failed. Storing the full response alongside the key, and returning that stored response verbatim on a repeat, is what actually closes the loop for the client.

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'processing',
  response_body JSONB,
  response_status INT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The Race Condition Between Two Concurrent Retries

A client that retries aggressively, or a flaky network causing two near-simultaneous requests with the same key, can hit the endpoint before the first attempt has finished processing and written its result. Checking "does this key exist" with a plain SELECT, then proceeding if it doesn't, has an obvious race: both requests can pass that check before either has inserted a row. Using an atomic INSERT ... ON CONFLICT DO NOTHING as the very first step, and checking whether the insert actually happened, closes this: exactly one concurrent request wins the insert and proceeds to actually charge the card, the other sees the conflict and knows to wait for or return the winner's result.

async function handleCharge(req, res) {
  const key = req.headers['idempotency-key'];
  if (!key) return res.status(400).json({ error: 'idempotency key required' });

  const requestHash = hashRequestBody(req.body);
  const inserted = await db.query(
    `INSERT INTO idempotency_keys (key, request_hash, status)
     VALUES ($1, $2, 'processing')
     ON CONFLICT (key) DO NOTHING
     RETURNING key`,
    [key, requestHash]
  );

  if (inserted.rowCount === 0) {
    // Key already exists — either still processing or completed
    const existing = await db.query(
      `SELECT * FROM idempotency_keys WHERE key = $1`, [key]
    );
    const row = existing.rows[0];
    if (row.status === 'processing') {
      return res.status(409).json({ error: 'request still processing, retry shortly' });
    }
    if (row.request_hash !== requestHash) {
      return res.status(422).json({ error: 'idempotency key reused with different request body' });
    }
    return res.status(row.response_status).json(row.response_body);
  }

  // We won the insert race — actually perform the charge
  const result = await processCharge(req.body);
  await db.query(
    `UPDATE idempotency_keys
     SET status = 'completed', response_body = $2, response_status = $3
     WHERE key = $1`,
    [key, result.body, result.status]
  );
  return res.status(result.status).json(result.body);
}

Guarding Against Key Reuse With a Different Payload

A client bug that accidentally reuses an idempotency key across two genuinely different requests, different amount, different recipient, is a real risk worth guarding against explicitly, not just trusting the client to generate keys correctly. Hashing the request body and storing that hash alongside the key, then rejecting a repeat with a mismatched hash rather than silently returning the wrong cached response, catches this as a client error instead of a silent data integrity problem.

What to Do With the "Still Processing" Case

A retry arriving while the original request is still mid-flight, the charge hasn't completed yet, can't return a final result because there isn't one yet. Returning a 409 Conflict with a message telling the client to retry shortly, rather than blocking the second request until the first finishes, avoided a class of connection-pool exhaustion issues we'd have hit holding requests open waiting on another in-flight request's lock.

Expiring Keys Instead of Keeping Them Forever

Idempotency keys don't need to live forever, Stripe's own documentation describes a 24-hour window as reasonable, since a client retrying a request from three weeks ago isn't really the same logical operation anymore. A scheduled cleanup job deleting rows older than that window kept the table from growing unbounded, while still covering every realistic retry scenario a flaky mobile network actually produces.

Final Verdict

The core idea, remember what you've already done and return the same result on a repeat, sounds trivial until the concurrent-request race condition and the mismatched-payload edge case force real design decisions. The atomic INSERT ... ON CONFLICT as the serialization point is the piece that makes this actually correct under concurrency, not just correct in a single-threaded mental model of how retries happen.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles