Idempotency
Safely retry POST requests without creating duplicate resources using the Idempotency-Key header.
Last updated 2026-06-11
The Core API supports the Idempotency-Key header on POST requests. When a client retries a request with the same key, the server replays the original response instead of re-executing the operation. This prevents duplicate resource creation caused by network timeouts, AI agent retries, or webhook handler failures.
Quick start
Add an Idempotency-Key header to any POST request. The key must be a unique string between 1 and 256 characters. UUIDs work well.
curl -X POST https://api.authpi.com/v1/accounts/{account_id}/issuers \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"name": "my-issuer"}'
If the request succeeds and you send the exact same request again with the same key, the server returns the cached response with an additional header:
Idempotent-Replayed: true
The response body and status code are identical to the original.
How it works
Lifecycle of an idempotency key
- First request — The server records the key, executes the handler, and caches the response.
- Replay — A subsequent request with the same key and body returns the cached response immediately. Authorization scopes are still checked, but the handler is not re-executed and no credits are charged.
- Expiry — Keys expire automatically after 24 hours. After expiry, the same key can be reused.
Scope
- Keys are scoped per account. The same key used by different accounts will not collide.
- The request fingerprint includes both the URL path and the request body. The same key used on different endpoints (e.g.,
/webhooksvs/issuers) is treated as a mismatch.
What gets cached
Only successful responses (2xx status codes) are cached. If the original request fails with a 4xx or 5xx error, the key is released so you can retry.
Responses larger than 512 KB are marked as completed but the body is not stored. A replay will return the original status code but with an empty body.
Error responses
422 — Body mismatch
If you reuse a key with a different request body, the server returns 422 Unprocessable Content:
{
"error": "invalid_request",
"error_description": "Idempotency-Key has already been used with a different request body"
}
This protects against accidental key reuse. Always generate a new key for each distinct operation.
409 — Concurrent request
If two requests with the same key arrive simultaneously, the second one receives 409 Conflict with a Retry-After header:
{
"error": "conflict",
"error_description": "A request with this Idempotency-Key is currently being processed"
}
Wait for the duration specified in Retry-After (typically 5 seconds), then retry.
400 — Invalid key
Keys must be between 1 and 256 characters. Empty keys or keys exceeding 256 characters return 400 Bad Request.
Secret-returning endpoints
Endpoints that return one-time secrets are excluded from idempotency caching. These endpoints document that the secret is “returned once” and cannot be retrieved again. Caching the response would break that guarantee.
The following POST endpoints are excluded:
| Endpoint | Secret field |
|---|---|
| Create API key | secret_plain |
| Rotate API key secret | secret_plain |
| Create personal token | token_plain |
| Create client (confidential) | secret |
| Rotate client secret | secret |
| Create webhook (bearer/signature auth) | bearer_token_plain / signature_secret_plain |
| Create agent verifier (secret type) | secret |
| Regenerate backup codes | plaintext codes |
| Complete TOTP enrollment | backup codes |
For these endpoints, the Idempotency-Key header is accepted but the response is not cached. Retrying will re-execute the handler, and the resource’s own duplicate-prevention logic will apply.
Non-POST methods
The Idempotency-Key header is silently ignored on GET, PATCH, and DELETE requests. These methods are naturally idempotent by HTTP semantics:
- GET — Read-only, always safe to retry.
- PATCH — Applies the same update, producing the same result.
- DELETE — Deleting an already-deleted resource returns 404.
Best practices
Generate a unique key per operation
Use a UUID v4 or another high-entropy random string. Never reuse keys across different operations.
const key = crypto.randomUUID();
Store the key with your request
If your client might crash mid-request, persist the idempotency key alongside the request parameters before sending. On restart, replay with the same key.
// Before sending
const key = crypto.randomUUID();
await db.insert({ key, action: "create_user", body });
// Send (safe to retry on failure)
const res = await fetch(url, {
method: "POST",
headers: { "Idempotency-Key": key, ... },
body: JSON.stringify(body),
});
// After confirmed success
await db.delete({ key });
Handle the Idempotent-Replayed header
Check for the Idempotent-Replayed: true response header to distinguish replays from original responses in your logging:
const replayed = res.headers.get("Idempotent-Replayed") === "true";
if (replayed) {
console.log("Response was replayed from cache");
}
Retry strategy
Combine idempotency keys with exponential backoff:
async function safePost(url, body, maxRetries = 3) {
const key = crypto.randomUUID();
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, {
method: "POST",
headers: {
"Idempotency-Key": key,
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (res.status === 409) {
// Concurrent processing — wait and retry
const retryAfter = parseInt(res.headers.get("Retry-After") || "5");
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
return res;
}
throw new Error("Max retries exceeded");
}