> Markdown version of https://authpi.com/docs/reference/idempotency/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Idempotency

Safely retry POST requests without creating duplicate resources using the Idempotency-Key header.

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.

```bash
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

1. **First request** &mdash; The server records the key, executes the handler, and caches the response.
2. **Replay** &mdash; 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.
3. **Expiry** &mdash; 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., `/webhooks` vs `/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 &mdash; Body mismatch

If you reuse a key with a different request body, the server returns `422 Unprocessable Content`:

```json
{
  "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 &mdash; Concurrent request

If two requests with the same key arrive simultaneously, the second one receives `409 Conflict` with a `Retry-After` header:

```json
{
  "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 &mdash; 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** &mdash; Read-only, always safe to retry.
- **PATCH** &mdash; Applies the same update, producing the same result.
- **DELETE** &mdash; 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.

```typescript
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.

```typescript
// 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:

```typescript
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:

```typescript
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");
}
```