Skip to content
All posts

Idempotency keys: making retries safe

Networks fail halfway through requests. Idempotency keys let clients retry payments, orders and webhooks without the work happening twice.

3 min read

A client sends POST /payments. Your server charges the card, writes the row, and starts sending 201 Created. Then the connection drops. The client never sees the response, so as far as the client knows, the request failed.

If it retries, the customer might pay twice. If it gives up, the order might never go through. The client has no way to tell which case it's in. With idempotency keys, the client can always retry and the server makes sure the work happens once.

The contract

The client generates a unique key for each logical operation and sends it with every attempt of that operation:

POST /payments HTTP/1.1
Idempotency-Key: 5f1c9a0e-8d2b-4e61-9a55-3b0f6d2c7e14
Content-Type: application/json
 
{ "amount": 4200, "currency": "usd", "source": "card_123" }

The server promises three things:

  1. The first request with a key does the work and stores the response.
  2. A repeat with the same key and the same body gets the stored response back. Nothing runs again.
  3. The server rejects a repeat with the same key and a different body. The client has a bug, and guessing what it meant would be worse.

Storing keys next to the work

Keep the keys in the same database as the data the request changes, and write both in one transaction. Then "the work happened" and "the key was recorded" can never disagree.

create table idempotency_keys (
  account_id   bigint      not null,
  key          text        not null,
  request_hash text        not null,
  status       text        not null check (status in ('started', 'completed')),
  response     jsonb,
  created_at   timestamptz not null default now(),
  primary key (account_id, key)
);
export async function withIdempotency(
  db: Db,
  scope: { accountId: number; key: string; requestHash: string },
  work: (tx: Tx) => Promise<StoredResponse>,
): Promise<StoredResponse> {
  return db.transaction(async (tx) => {
    const claimed = await tx.query(
      `insert into idempotency_keys (account_id, key, request_hash, status)
       values ($1, $2, $3, 'started')
       on conflict (account_id, key) do nothing
       returning key`,
      [scope.accountId, scope.key, scope.requestHash],
    );
 
    if (claimed.rowCount === 0) {
      const existing = await tx.one(
        `select request_hash, status, response from idempotency_keys
         where account_id = $1 and key = $2 for update`,
        [scope.accountId, scope.key],
      );
      if (existing.request_hash !== scope.requestHash)
        throw new ConflictError("Key reused with a different request");
      if (existing.status === "completed") return existing.response;
      throw new RetryLaterError("A request with this key is still in progress");
    }
 
    const response = await work(tx);
    await tx.query(
      `update idempotency_keys set status = 'completed', response = $3
       where account_id = $1 and key = $2`,
      [scope.accountId, scope.key, response],
    );
    return response;
  });
}

Two concurrent requests with the same key can't both do the work. The second insert waits on the unique index until the first transaction commits, then finds the completed row and returns its response.

Details to get right

  • Scope keys to the caller. The composite primary key above stops one tenant from replaying another tenant's response.
  • Hash a canonical body. Sort JSON keys before hashing, or harmless reformatting on the client looks like a conflict.
  • Expire old keys. Twenty-four hours covers any reasonable retry policy. Delete older rows with a scheduled job.
  • Only store deterministic failures. A 400 for an invalid card number should replay. A 503 because your database was briefly down should not, or that client can never succeed.

Where to use it

Use it anywhere a client might retry something with side effects, such as payments, order creation, sending email and provisioning resources. Webhook handlers get it almost for free, since the provider's event ID makes a natural key. It costs one table and a transaction boundary, and it rules out a whole category of "the customer was charged twice" incidents.