Skip to content

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

  1. You send a request with an Idempotency-Key header
  2. 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)
  3. First time: Processes the request normally, stores the response keyed to your idempotency key
  4. Subsequent times (same body): Returns the stored response without re-processing
  5. Same key, different request body: Returns 409 Conflict with code bad_request and 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.