metadlabs
IntegrationsGuide

Idempotency Key Best Practices for APIs, Payments and Webhooks

How idempotency keys make API retries safe: how to generate them, store them, handle concurrent requests, and apply the same idea to webhooks and payments.

By Metad Labs team · Sep 25, 2026 · 7 min read

Key takeaways

  • An idempotency key lets a client safely retry a request: the server performs the operation once and returns the same result for every retry.
  • Generate the key on the client, once per logical operation (a UUID is fine), and reuse it on every retry of that operation.
  • On the server, store key, request fingerprint and response atomically; reject the same key with a different payload, and lock concurrent duplicates.
  • Webhooks need the mirror image: deduplicate incoming events by their event ID, because every provider delivers at least once.

Networks fail in the worst possible way. The server does the work, and then the response gets lost before the client sees it. The client cannot tell whether the payment went through, so it retries, and now the customer is charged twice. Idempotency keys are the standard fix. Below is how to implement them properly, including the edge cases most tutorials skip.

What an idempotency key does

The client attaches a unique key to any request that has side effects.

http
POST /v1/payments
Idempotency-Key: 9f1c2b7e-4a0d-4c1e-8a57-3d2f6c0e9b11
Content-Type: application/json

{ "amount": 4900, "currency": "usd", "customer": "cus_123" }

In return, the server makes a promise. The first request with that key gets executed. Any retry with the same key gets back the same stored response, status code and body included, and nothing runs a second time. And if the same key arrives with a different payload, the server rejects it, because that can only mean a bug in the client.

Once that holds, the operation is safe to retry. Your clients and queues can retry aggressively on timeouts and 5xx errors, and so can your SDKs.

Generating keys on the client

Use a random UUID. Version 4 is fine, or v7 if you want keys that sort by time. There is no need to hash the payload.

The key belongs to the logical operation, so create it once, for example when the user submits the checkout form. A fresh key for every HTTP attempt defeats the point. Persist it next to the pending operation (local state, a job row or an outbox table) so that a retry after a crash or restart reuses it. On the server side, scope keys per user or API account so two tenants can never collide.

Implementing it on the server

A solid implementation needs a table, a fingerprint and a lock. Here is the table in Postgres.

sql
CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  request_hash  text        NOT NULL,
  status        text        NOT NULL DEFAULT 'in_progress', -- in_progress | completed
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);

And the request flow that uses it:

ts
async function handleWithIdempotency(req, handler) {
  const key = req.header("Idempotency-Key");
  if (!key) return handler(req);                       // or require it for this endpoint

  const hash = sha256(req.method + req.path + canonicalJson(req.body));

  // 1. Claim the key atomically. Only one request can insert it.
  const claimed = await db.query(
    `INSERT INTO idempotency_keys (account_id, key, request_hash)
     VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key`,
    [req.accountId, key, hash],
  );

  if (claimed.rowCount === 0) {
    const existing = await db.one(
      `SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2`,
      [req.accountId, key],
    );
    if (existing.request_hash !== hash) return res(422, "Idempotency-Key reused with a different request");
    if (existing.status === "in_progress") return res(409, "Request with this key is still in progress");
    return res(existing.response_code, existing.response_body);   // replay
  }

  // 2. Execute and store the result.
  const result = await handler(req);
  await db.query(
    `UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4
     WHERE account_id = $1 AND key = $2`,
    [req.accountId, key, result.status, result.body],
  );
  return result;
}

A few details in that code carry most of the weight.

The claim has to be atomic. Because INSERT ... ON CONFLICT DO NOTHING runs against a primary key, two concurrent retries cannot both execute. Read first and insert afterward, and you have a race.

The fingerprint covers the case where someone reuses a key for a different request. Replaying the stored response there would be silently wrong, so return a 4xx instead. A retry that shows up while the first request is still running gets a 409, and the client tries again later.

Store the response even when it is an error. If the first attempt failed with a validation error, the retry should see that same error. Server errors are worth treating differently. Consider not storing 5xx responses, so a transient failure can be retried with the same key.

Which status codes? The IETF Idempotency-Key header draft suggests 400 for a missing key and 422 for a key reused with a different payload. For a request still in flight it suggests 409. The draft expired without becoming a standard, yet following it keeps your API predictable.

Then there is the crash in the middle. If the process dies after claiming the key, that row stays in_progress, so expire those rows after a timeout. Where you can, also make the side effect itself safe to re-run by doing the work and the key update in one database transaction.

Last comes retention. Delete keys once they are past your retry window, with a scheduled job. 24 hours is a common default.

Idempotency for payments

When you call a payment provider, pass your own idempotency key through. Stripe accepts one. So do Adyen and PayPal, along with most other payment APIs. Derive the key from your own operation ID, for example order_8812_charge, so that a retry of your job reuses the same key at the provider too. A retry at any layer then results in one charge, whether it came from the browser, from your API or from your background job.

Check your provider's exact semantics, because they differ. Stripe alone shows how much. As of September 2026, its two API versions behave like this.

Stripe API v1Stripe API v2
Key retentionPruned after at least 24 hours30 days
Retry after the first attempt failedReplays the saved result, even a 500Re-executes the request
Key reused with different parametersErrorError

Two rules follow. Treat a 500 or a timeout from the provider as indeterminate, since the charge may or may not have happened. Retry with the same key and reconcile via webhooks instead of assuming failure. After a clear 4xx validation error it goes the other way, and you fix the request and use a new key, because the old one may be bound to the failed attempt.

The other direction: webhooks

Providers deliver webhooks at least once. You will receive duplicates, sometimes out of order, and the receiving side needs its own deduplication.

sql
CREATE TABLE processed_events (
  provider   text NOT NULL,
  event_id   text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);
  1. Verify the signature.
  2. INSERT the event ID. If it conflicts, return 200 and stop, because you already handled it.
  3. Process in the same transaction as the insert, or enqueue a job keyed by the event ID.
  4. Return 2xx fast; do slow work asynchronously so the provider does not time out and redeliver.

Ordering needs a different fix. Do not trust arrival order at all. Re-fetch the current state of the object from the provider's API, or compare the event's timestamp or version with what you have stored.

Checklist

  1. Require Idempotency-Key on every POST that creates something or has side effects.
  2. Clients generate one UUID per operation and persist it across retries.
  3. Claim keys atomically with a unique constraint.
  4. Fingerprint requests; reject key reuse with a different payload.
  5. Return 409 for in-flight duplicates; replay stored responses for completed ones.
  6. Expire keys after your retry window.
  7. Pass keys through to payment providers.
  8. Deduplicate incoming webhooks by event ID.

Most integration bugs we fix come down to retries without idempotency. They show up as double charges, duplicate orders and missing syncs. For how retries interact with load, see why APIs slow down under load. If you are connecting AI agents to these same APIs, MCP servers vs APIs covers how to do it safely.

Frequently asked questions

What is an idempotency key?

A unique value the client sends with a request, usually in an Idempotency-Key header, so the server can recognize retries of the same operation. The first request is executed; later requests with the same key return the stored result instead of executing again.

How should I generate an idempotency key?

Generate a random UUID (v4 or v7) on the client when the user starts the operation, for example when they click Pay, and persist it with the pending operation so retries reuse it. Do not generate a new key per HTTP attempt, or retries will not be recognized.

How long should idempotency keys be stored?

Long enough to cover every realistic retry window, including client retries with backoff and queued jobs. 24 hours is a common default (Stripe's v1 API prunes keys after at least 24 hours), while Stripe's newer v2 API keeps them for 30 days. Payment flows or batch jobs that retry over days need the longer window.

What status code should an API return for idempotency conflicts?

The IETF Idempotency-Key header draft suggests 400 when a required key is missing, 422 when a key is reused with a different request body, and 409 when a retry arrives while the original request is still being processed. The draft has expired without becoming an RFC, but these codes are widely used.

Are GET, PUT and DELETE already idempotent?

By HTTP semantics they should be, meaning repeating them leaves the server in the same state. POST and PATCH are not, which is why idempotency keys are mainly used for POST requests that create resources or trigger side effects like charges, emails or shipments.

Systems that don't talk to each other?

We build integrations with retries, idempotency and monitoring, so data stops going missing.

Book a free 30-min callor see the fixed-price Metad Diagnostic →

Newsletter

Get the next post in your inbox

Practical write-ups on fast systems, AI and automation from the engineers doing the work. About every two weeks.

No pitch. Unsubscribe from any email.

Keep reading