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.
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.
/webhooks vs /issuers) is treated as a mismatch.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.
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.
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.
Keys must be between 1 and 256 characters. Empty keys or keys exceeding 256 characters return 400 Bad Request.
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.
The Idempotency-Key header is silently ignored on GET, PATCH, and DELETE requests. These methods are naturally idempotent by HTTP semantics:
Use a UUID v4 or another high-entropy random string. Never reuse keys across different operations.
const key = crypto.randomUUID();
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 });
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");
}
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");
}