Idempotency¶
How to safely retry API requests without creating duplicate resources.
The Problem¶
Network failures happen. If your request to create an invoice succeeds on the server but the response is lost in transit, you don't know whether to retry. Retrying could create a duplicate invoice.
The Solution¶
Include an Idempotency-Key header with a unique identifier for each operation. If you retry the same request with the same key, the API returns the original response instead of creating a duplicate.
curl -X POST https://invoicepdfs.com/api/v1/documents \
-H "Authorization: Bearer ip_live_..." \
-H "Idempotency-Key: inv-create-2026-07-001" \
-H "Content-Type: application/json" \
-d '{ ... }'
How It Works¶
- You send a request with an
Idempotency-Keyheader - The server checks if it has seen this key before (scoped to your account and the specific endpoint — the same key value on two different endpoints is treated as two independent operations)
- First time: Processes the request normally, stores the response keyed to your idempotency key
- Subsequent times (same body): Returns the stored response without re-processing
- Same key, different request body: Returns
409 Conflictwith codebad_requestand the message "Idempotency-Key reuse with different request body" — the request is not processed
First request: POST /documents + Idempotency-Key: abc123 → 200 OK (document created)
Retry: POST /documents + Idempotency-Key: abc123 → 200 OK (same response, no duplicate)
Changed body: POST /documents + Idempotency-Key: abc123 → 409 Conflict (body hash mismatch)
Different key: POST /documents + Idempotency-Key: def456 → 200 OK (new document created)
Which Endpoints Support It?¶
Only specific endpoints accept the Idempotency-Key header. It is not honored
on every write endpoint — for example PATCH /api/v1/documents/{id}, the status
transitions (finalize, void, mark-sent, mark-paid, ...), and
POST /api/v1/documents/{id}/send do not accept it. The full list of endpoints
that honor the header:
| Endpoint | Recommended Key Format |
|---|---|
POST /api/v1/documents |
doc-{your_number} |
POST /api/v1/documents/{id}/renders |
render-{document_id}-{attempt} |
POST /api/v1/documents/render |
render-{content_hash} |
POST /api/v1/business-profiles |
bp-{your_internal_id} |
PATCH /api/v1/business-profiles/{id} |
bp-update-{your_internal_id} |
POST /api/v1/customers |
cust-{your_crm_id} |
PATCH /api/v1/customers/{id} |
cust-update-{your_crm_id} |
POST /api/v1/files |
file-{content_hash} |
POST /api/v1/templates/{id}/preview |
preview-{content_hash} |
POST /api/v1/workspaces |
ws-{name_slug} |
PATCH /api/v1/workspaces/{id} |
ws-update-{name_slug} |
POST /api/v1/workspaces/{id}/members |
member-{email_slug} |
GET and DELETE requests are naturally idempotent and don't need the header.
Best Practices¶
Use deterministic keys¶
Generate keys from your data rather than random UUIDs when possible. This way retries from crashes automatically deduplicate:
# Good — deterministic, retries naturally deduplicate
key = f"inv-{customer_id}-{period}-{invoice_number}"
# Also fine — but you must store and reuse the UUID on retry
key = str(uuid.uuid4())
One key per logical operation¶
Don't reuse keys across different operations:
# Wrong — same key for different invoices
client.post("/api/v1/documents", json=invoice_a, headers={"Idempotency-Key": "my-key"})
client.post("/api/v1/documents", json=invoice_b, headers={"Idempotency-Key": "my-key"})
# the second call is rejected with 409 (same key, different body)
# Correct — unique key per operation
client.post("/api/v1/documents", json=invoice_a, headers={"Idempotency-Key": "inv-a"})
client.post("/api/v1/documents", json=invoice_b, headers={"Idempotency-Key": "inv-b"})
Implement retry with backoff¶
import httpx
import time
def create_invoice_safely(client, data, idempotency_key, max_retries=3):
for attempt in range(max_retries):
try:
resp = client.post(
"/api/v1/documents",
json=data,
headers={"Idempotency-Key": idempotency_key},
)
resp.raise_for_status()
return resp.json()["data"]
except httpx.NetworkError:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 1s, 2s, 4s
continue
raise
Key Expiration¶
Idempotency keys are stored for 24 hours. After that, the same key can be used for a new request. This prevents unbounded storage growth while covering typical retry windows.