Appearance
Idempotency
Attach an X-Idempotency-Key to a partner-facing POST and the gateway guarantees the operation runs at most once — a retry carrying the same key returns the original outcome instead of executing again. This makes a POST safe to retry under the same automatic backoff you would use for a read: a timed-out or dropped request can be sent again without risking a duplicate order or payout.
The key is a transport-level guarantee, distinct from clientRef on the Intent body. clientRef is an opaque label you attach for your own correlation and the gateway echoes it back; it is not used for deduplication. Use X-Idempotency-Key to make a retry safe, and clientRef to reconcile the operation in your own systems.
Scope
Idempotency applies to every partner-facing POST — quote, order execution, and intent submission. It is POST-only; other methods are excluded until explicitly introduced, and a key sent on a non-POST request is ignored. Inbound webhooks are out of scope — they are deduplicated separately by their own signature and event id.
Because the guarantee is transport-level, it also covers read-ish requests such as quote/RFQ.
A retried quote replays the original candidates, which may already be past their
ttlSec. Mint a new key when you want a fresh quote rather than a safe replay of the previous one.
Request Header
| Header | Required | Description |
|---|---|---|
X-Idempotency-Key | Yes (partner-facing POST) | Canonical RFC 9562 UUID identifying the operation; v4 recommended. Use one key per operation and reuse it across every retry. |
http
POST /v1/orders
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/jsonA key that is not a canonical RFC 9562 UUID is rejected with IDEMPOTENCY_KEY_INVALID (400).
Key Scope
An idempotency record is identified by the tuple (principal, method, operation, key):
- Principal — the stable identity of the authenticated partner, not the raw API key. Under key rotation a new key can be active alongside the old one, so scoping by raw key would route a retry into a different namespace and execute twice. The principal is rotation-stable, and the raw key is never stored.
- Operation — the stable logical operation the request targets (its normalized route template), resolved after any gateway path rewrite. It is deliberately not tied to a literal URL path, so it survives path and versioning changes.
A key is never resolvable across principals, and the same key on a different operation is an independent record, not a conflict — reuse one across a quote and an order and both run. Mint a fresh key per operation.
Request Matching
To tell a genuine retry from a different request that reuses a key, the gateway compares a fingerprint of the request body — a one-way hash, not a stored copy of the body, isolated per principal. Method and operation are already fixed by the key scope, so the fingerprint covers only the canonical request body (plus any operation-affecting query parameters, sorted).
Canonicalization is defined over application/json bodies: object keys are sorted (field order is irrelevant), arrays keep their order, null is preserved, and whitespace outside strings is ignored. String values are compared verbatim and never numerically normalized — every monetary field here is a decimal string, so "1000" and "1000.00" are different requests, and the string "1000" never equals the number 1000. A change in representation is treated as a different operation rather than silently merged.
The rule set carries a canonicalizationVersion. Each record stores the version it was created under, and an incoming request is re-fingerprinted under that stored version for the record's lifetime, so shipping a new version never turns existing keys into false conflicts.
A request whose fingerprint cannot be computed — a non-JSON body, a missing Content-Type, or a malformed or empty JSON body — is rejected with 400 before any side effect and is not stored.
This is easy to hit by accident —
curldefaults to form encoding, so always sendContent-Type: application/json.
Request Lifecycle
The gateway persists the idempotency record before any side effect, so the operation is recoverable across a crash. Behavior depends on the state of the existing record:
| Existing record | Same request (matching fingerprint) | Different request (different fingerprint) |
|---|---|---|
| None | Execute; the record is persisted before the side effect | Execute (an independent operation) |
| Processing / unknown outcome | 409 IDEMPOTENCY_KEY_PROCESSING (with Retry-After); the outbound is not re-sent | 409 IDEMPOTENCY_KEY_CONFLICT |
| Completed (confirmed terminal) | Replay the original status and body (with X-Idempotency-Replayed: true) | 409 IDEMPOTENCY_KEY_CONFLICT |
| Retention expired | Treated as a new request | Treated as a new request |
A record is confirmed terminal — and therefore replayable — when its outcome is settled: a 2xx, a deterministic business 4xx (such as a validation rejection), or a 5xx only when there is positive proof no side effect occurred (the request failed before the persisted side-effect step).
An unknown outcome is not a terminal 5xx. When the gateway cannot confirm whether an external side effect was applied — an outbound timeout, an unconfirmed call — the record stays Processing, retries receive 409 IDEMPOTENCY_KEY_PROCESSING with no outbound resend, until reconciliation resolves it. When in doubt the gateway treats the outcome as unknown rather than replayable; it never reports success for an unconfirmed operation.
On restart, an in-flight key is resolved from the stored operation state — the gateway reconstructs the completed response or keeps the record in Processing, and never re-executes. Processing and unknown records are not deleted or re-run by retention expiry, which applies only to confirmed-completed records.
Some responses are never stored or replayed, because they say nothing about the operation itself: 401 / 403, the header errors (IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_INVALID), 429, and idempotency's own 409s.
Replayed Responses
A replay reproduces the original response verbatim — the same HTTP status and body captured at first submission — and carries the X-Idempotency-Replayed: true header. The first (fresh) response omits it. Only the original status and body are reused; per-request headers (X-Request-Id, tracing, Date) are reissued.
http
HTTP/1.1 200 OK
X-Idempotency-Replayed: true
Content-Type: application/json
{
"success": true,
"data": { "orderId": "ord_2f8a3b1c", "status": "accepted" }
}The stored response reflects state at first submission — for a money-moving POST, that is a submission-time acknowledgement, while the operation then advances asynchronously (settlement, reversal). A replay is not a re-evaluation of current state, so read live state from the Status API or the settlement notification, not from the replayed response.
Concurrent Requests
A second request that arrives with the same key while the first is still in flight — or its outcome is unconfirmed — returns 409 IDEMPOTENCY_KEY_PROCESSING immediately, and the outbound is not re-sent:
http
HTTP/1.1 409 Conflict
Retry-After: 1
Content-Type: application/json
{
"success": false,
"error": {
"code": "IDEMPOTENCY_KEY_PROCESSING",
"message": "A request with this Idempotency-Key is still being processed."
}
}Retry-After is a floor, not a schedule. An operation can stay unconfirmed for a while — a fiat settlement takes minutes — so honor Retry-After as the lower bound and back off exponentially with a cap rather than tight-looping.
PROCESSING clears only when the operation resolves server-side (a settlement notification, or a Status API read), not by retrying harder, so bound your retries by attempt count or elapsed time.
Do not resubmit a stuck operation. An unresolved
PROCESSINGis not a failure. Resubmitting it — under a newX-Idempotency-Keyor as a new order — is a new operation the idempotency layer cannot deduplicate against the first, so it can execute a second time and double-pay. Reconcile out of band instead.
IDEMPOTENCY_KEY_CONFLICT, by contrast, is terminal — you reused a key with a different body, so fix the request rather than retrying.
Key Retention
A completed record is retained for at least 24 hours after its response. Within that window a retry with the same key replays the stored response; after it expires, the same key is treated as a new request, and re-sending it will execute the operation again. Complete all retries well inside the window, and do not reuse a key for a new operation. Processing and unknown records are exempt from this expiry — they persist until reconciliation resolves them.
Recommended Client Pattern
- Generate one
X-Idempotency-Key(a v4 UUID) per operation and persist it before sending, so a retry after a crash reuses the same key. - Reuse that key on every retry of the operation.
- Back off exponentially, with a cap and jitter, honoring
Retry-Afteras a floor. - Retry on
IDEMPOTENCY_KEY_PROCESSING; stop onIDEMPOTENCY_KEY_CONFLICT(you reused a key with a different body). - After a bounded number of attempts still returning
PROCESSING, stop and reconcile — do not resubmit under a new key.
javascript
import { randomUUID } from 'node:crypto'
async function submitOrder(body, key = randomUUID(), maxAttempts = 6) {
// Persist `key` with the pending operation before the first send.
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch('https://api.ezys.io/v1/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Idempotency-Key': key },
body: JSON.stringify(body),
})
if (res.status !== 409) return res // a final outcome — replayed or fresh
const { error } = await res.json()
if (error?.code === 'IDEMPOTENCY_KEY_CONFLICT') throw new Error('Key reused with a different body.')
const floorMs = Number(res.headers.get('Retry-After') ?? 1) * 1000
await sleep(Math.max(floorMs, Math.min(30_000, 2 ** attempt * 1000)))
}
// Still processing — reconcile out of band; do not resubmit under a new key.
throw new Error('Still processing after retries — check operation status before resubmitting.')
}Error Codes
| Code | HTTP Status | Description |
|---|---|---|
IDEMPOTENCY_KEY_REQUIRED | 400 | The X-Idempotency-Key header is missing on a partner-facing POST. |
IDEMPOTENCY_KEY_INVALID | 400 | The key is not a canonical RFC 9562 UUID, or the request could not be fingerprinted. |
IDEMPOTENCY_KEY_CONFLICT | 409 | The same key was reused with a different request body. |
IDEMPOTENCY_KEY_PROCESSING | 409 | The original request for this key is still being processed. |
See the API Reference overview for the full error-code registry.
Notes
- All amounts are decimal strings and are matched verbatim —
"1000"and"1000.00"are different requests. - One
X-Idempotency-Keyper operation; reuse it on retries, never across a different operation, and never after its record expires. POST-only. A key on a non-POSTrequest is ignored.- Always send
Content-Type: application/json; an unfingerprintable request is rejected with400before any side effect. - A replay reflects submission-time state — read current state from the operation's status flow, not the replayed response.
