Engineering · 31 Aug 2026

Idempotency in payment intents: making retries boring.

Every network call can be repeated. The only question is whether repeating it is safe.

Published
31 Aug 2026
Reading time
7 min
Readers
—
Topics
StablePayAPI designReliabilityPayments
On this page

Key takeaways

  • Retries are inevitable; idempotency makes them harmless.
  • Give every create-request a client-generated key, and derive the intent from it deterministically.
  • Apply the same idea on the receiving side of webhooks, keyed on event id.
  • Test the ugly path: same key with different parameters, and concurrent duplicates.

A customer double-clicks. A load balancer retries. A mobile connection drops after the request succeeded. In every case the same create-payment call arrives twice, and the system has to decide whether that means two payments or one.

Idempotency is the property that makes that decision safe. It is unglamorous, and it prevents the kind of incident that ends up in a treasury review.

The problem in one picture

  1. Your service calls the gateway to create a payment intent.
  2. The gateway creates it and starts to respond.
  3. The response is lost — a timeout, a dropped connection.
  4. Your service, unsure, retries the request.
  5. Without idempotency, there are now two intents for one order.
Create intentRequest sent
Server commitsIntent exists
Response lostTimeout or dropped connection
Caller retriesUnsure, so it asks again
Duplicate intentTwo payments for one order
Without idempotency: one order, two intents
Retry, same keyorder-42-payment
Server finds the keyStored with the result
Returns the originalNothing new is created
With idempotency: the retry is boring

The idempotency key

The standard solution: the caller generates a unique key for the *logical* operation and sends it with the request. The server stores the key with the result. A repeat with the same key returns the original result instead of creating another.

Illustrative — the calling pattern, not the exact APITypeScript
// Derive the key from the business operation, not from the attempt.
const idempotencyKey = `order-${order.id}-payment`;

async function createIntent() {
  return fetch(`${GATEWAY_URL}/v1/payment-intents`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": idempotencyKey,
    },
    body: JSON.stringify({ reference: order.id, amount: order.total }),
  });
}

// Safe to retry: the same key always resolves to the same intent.

Key by operation, not by attempt

A random key per retry defeats the purpose. The key should identify what you are trying to do, so every attempt at the same thing shares it.

Key per attempt

  • A random key on every retry
  • Every retry looks like a new operation
  • The duplicate appears anyway

Key per operation

  • Derived from the business action, e.g. order id
  • Every attempt at the same thing shares it
  • Same key, same result

What the server has to do

SituationCorrect behaviour
First request with a keyCreate the intent; store the key and the result
Repeat, same key, same parametersReturn the stored result; create nothing
Repeat, same key, different parametersReject with a clear error — the caller has a bug
Concurrent duplicatesSerialise on the key so only one creates
Key expiredTreat as new — and document the window

The concurrent case deserves attention. Two requests can arrive within milliseconds. A read-then-write check has a race; a unique constraint on the key does not.

Illustrative — let the database enforce itSQL
CREATE TABLE payment_intents (
  id              uuid PRIMARY KEY,
  idempotency_key text NOT NULL,
  request_hash    text NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  UNIQUE (idempotency_key)
);

-- INSERT ... ON CONFLICT (idempotency_key) DO NOTHING, then read the row.
-- Compare request_hash to detect same-key-different-parameters.

The other half: receiving events

Idempotency applies on the way back too. Webhook delivery is at-least-once, so the same event can arrive more than once. The receiver should record each event identifier and ignore repeats.

  • Record the event id in the same transaction as the state change it causes.
  • Make state transitions monotonic: never move a payment backwards.
  • Return 2xx for duplicates — they are not errors.

Choosing a key lifetime

Keys cannot be kept forever, and the retention window is a design decision worth writing down.

WindowGood forWatch out for
MinutesDouble-clicks and immediate retriesSlow client retry policies outlasting the window
Hours to a dayMost application retry loops and queue redeliveriesStorage growth on high volume
Tied to the orderPayments with a natural business keyReusing the key for a genuinely new attempt

Whatever you choose, document it and return a clear error when a key is reused with different parameters. Silent overwrites are how idempotency becomes a source of incidents.

Tests worth writing

  1. Send the same create request ten times in parallel. Expect exactly one intent.
  2. Send the same key with a different amount. Expect a rejection, not a silent overwrite.
  3. Kill the response after the server commits. Retry. Expect the original intent back.
  4. Deliver the same webhook twice. Expect one order update.
  5. Deliver events out of order. Expect a consistent final state.

Idempotency review

  • Every create endpoint accepts a client-supplied key.
  • A database constraint, not a read-then-write check, enforces uniqueness.
  • Same key with different parameters returns a clear error.
  • Webhook handlers record event ids in the same transaction as the state change.
  • State transitions are monotonic: never move a payment backwards.

What HTTP itself says about idempotency

Idempotency is defined precisely in the HTTP specification: a method is idempotent if the intended effect on the server of several identical requests is the same as for a single request [1]. PUT and DELETE are idempotent by definition; GET, HEAD, and OPTIONS are safe as well. POST is not, which is exactly why creating a payment with POST needs an explicit mechanism.

MethodSafeIdempotentRetry blindly?
GETYesYesYes
PUTNoYesYes, if the body is the same
DELETENoYesYes
POSTNoNoOnly with an idempotency key

There is an active effort to standardise this at the protocol level: an IETF draft defines an Idempotency-Key HTTP header field [2]. Payment providers such as Stripe popularised the pattern [3]. Aligning your gateway's header name and error semantics with the emerging convention makes clients easier to write.

A complete server-side implementation

The essential requirement is that two concurrent requests with the same key can never both create an intent. The cleanest way to guarantee that is a unique constraint in the database, not an in-memory check. Here is a fuller sketch than the one earlier in this post.

Illustrative — the idempotency tableSQL
CREATE TABLE idempotency_keys (
  key            text PRIMARY KEY,
  request_hash   text NOT NULL,          -- hash of method, path, and body
  status         text NOT NULL CHECK (status IN ('in_progress','completed')),
  response_code  int,
  response_body  jsonb,
  created_at     timestamptz NOT NULL DEFAULT now(),
  locked_until   timestamptz             -- to recover from crashed workers
);
Illustrative — claim the key, do the work once, store the responseTypeScript
async function withIdempotency(key: string, hash: string, work: () => Promise<Response>) {
  return db.transaction(async (tx) => {
    // 1. try to claim the key; ON CONFLICT DO NOTHING makes this race-safe
    const claimed = await tx.query(
      `INSERT INTO idempotency_keys (key, request_hash, status)
       VALUES ($1, $2, 'in_progress') ON CONFLICT (key) DO NOTHING RETURNING key`,
      [key, hash],
    );

    if (claimed.rowCount === 0) {
      const row = (await tx.query(`SELECT * FROM idempotency_keys WHERE key = $1 FOR UPDATE`, [key])).rows[0];
      if (row.request_hash !== hash) return json({ error: "key reused with different request" }, 422);
      if (row.status === "completed") return json(row.response_body, row.response_code); // replay stored result
      return json({ error: "request in progress" }, 409);
    }

    // 2. first time: perform the work inside the same transaction
    const res = await work();
    await tx.query(
      `UPDATE idempotency_keys SET status='completed', response_code=$2, response_body=$3 WHERE key=$1`,
      [key, res.status, await res.clone().json()],
    );
    return res;
  });
}

Why a transaction matters

If the intent is created and the key is recorded in the same database transaction, there is no window in which one exists without the other. A crash rolls both back and the client's retry starts clean.

Edge cases that cause real incidents

CaseWhat goes wrongCorrect handling
Same key, different bodySilent overwrite or wrong paymentReject with a clear error; the client has a bug
Concurrent duplicatesTwo workers both create an intentUnique constraint plus ON CONFLICT
Worker crashes mid-requestKey stuck in progress foreverA lock timeout after which the key can be reclaimed
Response lost after commitClient retries and sees nothingReturn the stored response for the same key
Key reused after expiryA genuinely new attempt treated as a repeatDocument the retention window; return a distinct error if reused after it
Failed request stored as successA retry replays an error foreverDecide whether errors are cached; usually store only completed results or retryable-marked errors

Side effects that are not in your database

The transactional approach protects your own tables. It does not protect a call to an external system — sending an email, hitting another API — made inside the same request. If that call succeeds and your transaction then rolls back, a retry will perform it twice.

The standard remedy is the transactional outbox [4]: write the intent to do the side effect into an outbox table inside the same transaction, and let a separate worker perform it with its own idempotency guarantees. The state change and the promise to notify commit together or not at all.

RequestWith idempotency key
One transactionState change plus outbox row
WorkerReads the outbox
Side effectSent with its own key
Mark doneRecorded
Outbox pattern

The client's half: retry policy

The key only helps if the client retries sensibly. Retrying instantly in a tight loop can amplify an outage; the AWS Builders' Library describes how safe retries depend on both idempotent APIs and disciplined client behaviour [5].

  • Retry only what is safe. Network errors, timeouts, 429 and 5xx, with the same key.
  • Back off with jitter. Never hammer a struggling service.
  • Cap attempts and total time. Then surface a failure a human can act on.
  • Log the key with every attempt. It is the thread that ties retries together in an investigation.

Pat Helland's classic argument [6] is that idempotence is not an optional nicety in distributed systems; it is the property that lets independent parts recover without coordination. Treating it as a first-class part of the API, rather than a patch, is what makes retries boring.

Closing

Make retries boring. If every repeat of an operation is harmless, you can retry aggressively, recover simply, and stop treating a network blip as an incident.

References & further reading

  1. 1
    RFC 9110: HTTP Semantics — R. Fielding, M. Nottingham, J. Reschke, IETF, 2022See the sections on safe and idempotent methods.
  2. 2
    The Idempotency-Key HTTP Header Field (Internet-Draft) — IETF HTTPAPI working groupA draft, not yet a published standard.
  3. 3
  4. 4
    Pattern: Transactional outbox — Chris Richardson, microservices.io
  5. 5
    Making retries safe with idempotent APIs — Malcolm Featonby, Amazon Builders' Library
  6. 6
    Idempotence Is Not a Medical Condition — Pat Helland, ACM Queue, 2012
  7. 7
    PostgreSQL Documentation: INSERT (ON CONFLICT) — PostgreSQL Global Development Group
Found this useful? Share it

Get the next essay in your inbox

Practical writing on payments infrastructure, operations software, and shipping real systems. No spam, no sales sequence.

We only use your email to send the studio's writing. See the privacy policy.

About the author

Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.

All writing

Have a system like this to run?

Explore the catalog, or write down the problem and the constraints. We respond when the fit is real.

Free apps from the studio. Enter your email, get a private download link. Free for personal use.

Get them free

Have a product to sell? We review, list, and sell it for you — you keep 90% of every sale.

Apply to sell with us