Skip to navigation

Idempotency

Safely retry write requests without creating duplicates

Network failures, timeouts, and worker restarts make retries unavoidable. The Idempotency-Key header lets you retry a write request without risking duplicate side effects: the first successful response is cached for 24 hours, and any subsequent request with the same key is replayed verbatim from cache.

When to Use

Send Idempotency-Key on every state-changing request where a duplicate would be problematic — creating invoices, posting payments, bulk imports, etc.

The header is optional. If omitted, the request is processed normally without idempotency protection.

How It Works

  1. The client generates a unique key (UUID/ULID recommended) and sends it in the Idempotency-Key header.
  2. On the first request with that key, the server processes normally and caches the 2xx response.
  3. On a retry with the same key:
    • Content-Type: application/json (JSON tier) — body is compared using a SHA-256 hash:
      • Same body → cached response returned with X-Idempotent-Replayed: true.
      • Different body → 422 Unprocessable Entity (catches accidental key reuse with mutated payload).
    • All other content types (e.g. multipart file uploads) — key-only match: a retry with the same key returns 422 Unprocessable Entity, since the binary body cannot be hashed to confirm it matches the original. Use a new key for a new request.
  4. If a retry arrives while the first request is still being processed → 409 Conflict. Retry after the original completes.

Example

curl -X POST https://api.ottimate.com/v1/invoices \
-H "X-API-Key: YOUR_API_KEY" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{ "vendor_id": "...", "amount": 1234.56 }'

If the same request is retried with the same Idempotency-Key and body, the response is identical, no new invoice is created, and the response includes:

X-Idempotent-Replayed: true

Key Format

ConstraintValue
Length1–128 characters
Allowed charactersA–Z, a–z, 0–9, ., _, -, +, =, /
RecommendedUUIDv4 or ULID

Invalid keys are rejected with 400 Bad Request.

Scope and Lifetime

AttributeBehavior
Cache lifetime24 hours from the first response
ScopePer API key + HTTP method + path + idempotency key
Cached responsesOnly 2xx responses are cached. 4xx/5xx responses are not — retries are processed fresh.
API key rotationRotating your API key invalidates all cached entries scoped to the old key.
Because scope includes the path and method, the same Idempotency-Key value can be safely reused across different endpoints — they will not collide.

Response Codes

StatusMeaning
2xx + X-Idempotent-Replayed: trueReplay of a cached response. Side effects already happened on the original request.
208 Already ReportedThe original response was too large to cache (>64 KB) or was streamed. The original operation did succeed — fetch the current state via the corresponding GET endpoint.
400 Bad RequestMalformed Idempotency-Key header (length / charset).
409 ConflictA concurrent request with the same key is still being processed. Wait and retry.
422 Unprocessable EntitySame key was previously used with a different request body (JSON requests only).

Applicability

Idempotency-Key takes effect on most POST requests, but not all of them. Every endpoint that supports it lists Idempotency-Key in its parameter table, so treat the per-endpoint API reference as authoritative rather than assuming all POST endpoints are covered.

The header is ignored (no caching) for:

  • Non-POST methods (GET, PATCH, PUT, DELETE — currently)
  • Requests without an API key

Each of the five handles a repeat differently, and the status code alone does not tell you whether a request was the first or a repeat. Check the response body:

EndpointWhat a second identical request doesTells you apart from the first?
POST /v1/invoices/mark-exportedReturns 200 with the invoice under updated, the same body as the first request. The invoice stays exported.No
POST /v1/invoices/{id}/flagReturns 200 with the same success payload. An already-flagged invoice is left as it is and no second history entry is recorded.No
POST /v1/invoices/{id}/approveReturns 200 carrying the original approved_date and client_invoice_state: "pending_export". The invoice keeps a single approval and one APPROVED history entry.No
POST /v1/invoices/mark-unexportedReturns 200, but the invoice appears under skipped rather than updated, with the reason Invoice is not in exported state.Yes, from the body
POST /v1/invoices/{id}/approversReturns 400, with message.err set to Approver is already added to the Invoice. The approver added by the first request is unchanged.Yes

Repeating any of these five is safe in the sense that none of them creates a duplicate record — mark-exported, flag and approve are unaffected by a repeat, while mark-unexported and approvers report that the work was already done.

For multipart file uploads and other non-JSON content types, the server cannot verify the request body without loading the full upload into memory. A retry with the same key always returns 422 — use a new key for each new upload.