Skip to main content

Idempotency and retries

In one sentence

Send the same idempotency key with the same request and you get the stored response back without anything running twice; send it with a different request and you get a 409; and if the server tells you the outcome is ambiguous, read the resource before you retry.

Written for the engineer writing the retry loop.

The header​

Every POST, PATCH and DELETE under /v1 requires idempotency-key, 1 to 255 characters. A missing or over-long key is 400 idempotency_key_required and nothing runs.

The key is scoped to your workspace, the environment and the resource family (payment intents, refunds, webhook endpoints, and so on), so order-8812 used on a payment intent and on a refund are two different keys. Within a scope, the request is fingerprinted: method, URL and body.

The four outcomes​

You sendYou getWhat happened
A new keyThe normal responseThe request ran. The response was stored under the key.
The same key, the same requestThe stored status and body, byte for byteNothing ran. Safe to do forever.
The same key, a different method, URL or body409 idempotency_conflictNothing ran. You reused a key by mistake.
The same key while the first request is still running409 idempotency_in_progressNothing ran. Wait and resend the identical request.

There is a fifth, rarer case. A request that acquired the key and then died mid-way (a crashed process, a lost connection to a provider) holds a lease. When the lease is reclaimed, a retry answers 409 conflict with a message that the outcome is ambiguous. That message means: do not resend blindly. Read the resource (GET /v1/payment-intents/{id} or the list filtered by your reference) and continue from its real state.

Choosing keys​

Derive the key from something you already have, so a retry after a timeout can never create a second object:

order-8812-create POST /v1/payment-intents
order-8812-confirm POST /v1/payment-intents/pi_…/confirm
order-8812-refund-1 POST /v1/refunds

Do not use a fresh random value per attempt; that defeats the purpose. Do not reuse one key across different requests; that is a 409.

A retry loop that is safe​

async function call(method, path, body, idempotencyKey, attempt = 0) {
const res = await fetch(API + path, {
method,
headers: {
'x-api-key': KEY,
'x-pehmen-organization': ORG,
'x-pehmen-environment': 'test',
'content-type': 'application/json',
'idempotency-key': idempotencyKey,
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
if (attempt >= 5) throw new Error(`gave up after ${attempt} attempts: ${res.status}`);
const wait = Number(res.headers.get('retry-after') ?? 0) * 1000 || 250 * 2 ** attempt;
await new Promise((r) => setTimeout(r, wait));
return call(method, path, body, idempotencyKey, attempt + 1);
}
const json = await res.json();
if (res.status === 409 && json.error?.code === 'idempotency_in_progress') {
await new Promise((r) => setTimeout(r, 500));
return call(method, path, body, idempotencyKey, attempt + 1);
}
return { status: res.status, json, requestId: res.headers.get('request-id') };
}

Two details matter. The same idempotency key rides every retry, so a request that actually succeeded on the server before the connection dropped is replayed, not repeated. And a 409 conflict that is not idempotency_in_progress stops the loop, because it is telling you to look before you act.

Rate limits​

LimiterNumbersResponse
Per API keyBucket of 50, refilled at 10 per second429 rate_limited
Mutations per source IP600 per minute, burst 900429 with retry-after
Provider webhook inbox per IP3000 per minute, burst 4500, then a cooldown429 with retry-after

A 429 is safe to retry with the same idempotency key after the wait.

On your side of a webhook​

Deliveries can be replayed on purpose (from the dashboard) and, in rare cases, redelivered. Key your handling on Pehmen-Event-Id, or the body's id, and make the handler idempotent. A replay carries the same id and the same bytes.