Skip to content

Idempotency

Network drops happen. Retrying a POST /v1/payments without idempotency would create two payment rows. The fix: send an Idempotency-Key header.

Set Idempotency-Key: <unique string> on writes. The gateway:

  1. Hashes your request body alongside the key.
  2. Looks up (app_id, key) in the idempotency store.
  3. Two outcomes:
    • Same key, same body within 24 hours → returns the original response. The gateway never re-processed the request; the second call is a no-op.
    • Same key, different body409 Conflict with idempotency conflict — different body for same key. Don’t reuse keys for different requests.

After 24 hours, the entry expires and the same key can be used again.

  • POST /v1/payments — always. Generate a UUID per checkout-create attempt on your side; if your client retries the same network call, the same UUID gets reused and the gateway recognizes the replay.
  • POST /v1/refunds — always. Same pattern.
  • GETs — never. GETs are naturally idempotent; the header is ignored.

A few options:

StrategyProsCons
UUID per attempt (crypto.randomUUID())Simple, collision-freeNew UUID per retry → no dedup if your retry logic doesn’t preserve the key
UUID per logical operation (cache it in a row)Real dedup across retriesSlightly more code; you persist the key alongside the work item
Hash of the business intent (e.g. sha256(user_id + plan_id + timestamp))Deterministic, easy to debugRequires you to know the intent doesn’t change between retries

The middle option is the right default: when your code creates a checkout, persist idempotency_key = uuid() on the row, then use that key for the gateway call AND for any retry of the same row.

// At checkout creation time on your side:
const idemKey = randomUUID();
await db.checkout.update({ where: { id }, data: { idempotencyKey: idemKey }});
// Gateway call (potentially retried):
async function createGatewayPayment() {
const checkout = await db.checkout.findUnique({ where: { id }});
if (!checkout) throw new Error('checkout vanished');
return fetch('https://payment.uncle-z.com/v1/payments', {
method: 'POST',
headers: {
'X-PAY-Key': pk,
'X-PAY-Timestamp': ts,
'X-PAY-Signature': sig,
'Content-Type': 'application/json',
'Idempotency-Key': checkout.idempotencyKey, // ← stable across retries
},
body: requestBody,
});
}

The (app_id, idempotency_key) tuple. Two different apps using the same key string don’t collide.

What if my key collides with another product’s by accident?

Section titled “What if my key collides with another product’s by accident?”

It can’t — see above. Your app_id partitions the key space.

What if I send the same key with a different signed timestamp?

Section titled “What if I send the same key with a different signed timestamp?”

Same body → returns the original response (timestamp doesn’t enter the body hash). The gateway is checking that you didn’t change the SEMANTICS of the call, not that the headers are byte-identical.

  • Window: 24 hours from first call.
  • Key length: 1–256 characters.
  • Body fingerprint: SHA-256 of the request body, stored once per key.

You won’t hit these in normal use.