Idempotency and retries
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 send | You get | What happened |
|---|---|---|
| A new key | The normal response | The request ran. The response was stored under the key. |
| The same key, the same request | The stored status and body, byte for byte | Nothing ran. Safe to do forever. |
| The same key, a different method, URL or body | 409 idempotency_conflict | Nothing ran. You reused a key by mistake. |
| The same key while the first request is still running | 409 idempotency_in_progress | Nothing 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
| Limiter | Numbers | Response |
|---|---|---|
| Per API key | Bucket of 50, refilled at 10 per second | 429 rate_limited |
| Mutations per source IP | 600 per minute, burst 900 | 429 with retry-after |
| Provider webhook inbox per IP | 3000 per minute, burst 4500, then a cooldown | 429 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.