Idempotency keys: making payment APIs safe to retry

Networks fail, users double-click, and webhooks get delivered twice. Idempotency is what keeps those everyday events from becoming duplicate charges.

Every payment system eventually receives the same request twice. A mobile client times out and retries. A user taps "Pay" again because the spinner looked stuck. A load balancer retries an upstream call. A payment provider redelivers a webhook it isn't sure you received.

None of these are bugs on the caller's side. Retrying is the correct response to an uncertain failure. The server's job is to make sure that doing something twice has the same effect as doing it once. That property is idempotency, and in payments it's not optional.

The idempotency key pattern

The widely used approach is for the client to generate a unique key per logical operation and send it with the request, typically in an Idempotency-Key header. The server remembers the key and the response it produced. If the same key arrives again, the server returns the stored response instead of performing the operation a second time.

The key identifies the client's intent, not the HTTP request. A retry of "pay for order 123" must reuse the same key. A new payment attempt must use a new one.

Storing keys in Postgres

A table with a primary key on the idempotency key gives you the guarantee you need, enforced by the database:

CREATE TABLE idempotency_keys (
  key text PRIMARY KEY,
  request_hash text NOT NULL,
  status text NOT NULL CHECK (status IN ('processing', 'completed')),
  response_code integer,
  response_body jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

The request_hash detects a dangerous client mistake: reusing a key with a different request body. That should be an error, not a silent replay of an unrelated response.

The request flow

import type { Pool } from "pg"

interface ApiResponse {
  code: number
  body: unknown
}

export async function withIdempotency(
  db: Pool,
  key: string,
  requestHash: string,
  handler: () => Promise<ApiResponse>
): Promise<ApiResponse> {
  const claimed = await db.query(
    `INSERT INTO idempotency_keys (key, request_hash, status)
     VALUES ($1, $2, 'processing')
     ON CONFLICT (key) DO NOTHING`,
    [key, requestHash]
  )

  if (claimed.rowCount === 0) {
    const { rows } = await db.query(
      `SELECT request_hash, status, response_code, response_body
       FROM idempotency_keys WHERE key = $1`,
      [key]
    )
    const existing = rows[0]

    if (existing.request_hash !== requestHash) {
      return { code: 422, body: { error: "Idempotency key reused with a different request" } }
    }

    if (existing.status === "processing") {
      return { code: 409, body: { error: "A request with this key is still in progress" } }
    }

    return { code: existing.response_code, body: existing.response_body }
  }

  try {
    const response = await handler()

    await db.query(
      `UPDATE idempotency_keys
       SET status = 'completed', response_code = $2, response_body = $3
       WHERE key = $1`,
      [key, response.code, JSON.stringify(response.body)]
    )

    return response
  } catch (error) {
    // Release the key so the client can retry after an unexpected failure
    await db.query("DELETE FROM idempotency_keys WHERE key = $1", [key])
    throw error
  }
}

The INSERT ... ON CONFLICT DO NOTHING is the important line. Two concurrent requests with the same key can't both claim it, because only one insert succeeds. The other sees an existing row and either waits for the result or gets a 409.

There's one subtlety in the error path. Releasing the key is right for failures that happened before any money moved. If the handler fails after calling the payment provider, you don't know whether the charge went through, and releasing the key would allow a duplicate. For those cases, record the uncertain state and reconcile with the provider, usually by passing your own idempotency key through to them, rather than letting the client retry blindly.

Make state transitions explicit

Idempotency keys protect individual requests. Orders and payments also need protection at the domain level. An explicit state machine, where each transition is only valid from specific states, turns many duplicate operations into harmless no-ops:

UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending_payment'
RETURNING id;

If the order is already paid, this updates zero rows and the duplicate is detected without any special-case code.

Webhooks need idempotency too

Payment providers deliver webhooks at least once, so your handlers will see duplicates. Record each processed event ID, in the same transaction as the side effects it causes:

INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;

If the insert affects zero rows, the event was already handled, so acknowledge it and stop. Doing this inside the same transaction as the order update matters: recording the event and applying its effect must succeed or fail together.

Async processing and retries

Payment notifications and order changes are good candidates for queues, so a slow or failing downstream system doesn't break checkout. The rules carry over: every consumer must be idempotent, retries should back off, and messages that keep failing should land somewhere a human will look, not disappear.

A checklist for payment endpoints

  • Every state-changing endpoint accepts an idempotency key.
  • Keys are claimed atomically with a unique constraint, and the request body is fingerprinted.
  • Uncertain outcomes are reconciled with the provider, not retried blindly.
  • Domain state transitions are explicit and conditional.
  • Webhook events are deduplicated in the same transaction as their effects.
  • Queued work is idempotent, with backoff and a dead-letter destination.

None of this is exotic. It's the unglamorous work that lets everyone else in the system retry freely, which is exactly what you want them to do.