Skip to content

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

HeaderRequiredDescription
X-Idempotency-KeyYes (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/json

A 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 — curl defaults to form encoding, so always send Content-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 recordSame request (matching fingerprint)Different request (different fingerprint)
NoneExecute; the record is persisted before the side effectExecute (an independent operation)
Processing / unknown outcome409 IDEMPOTENCY_KEY_PROCESSING (with Retry-After); the outbound is not re-sent409 IDEMPOTENCY_KEY_CONFLICT
Completed (confirmed terminal)Replay the original status and body (with X-Idempotency-Replayed: true)409 IDEMPOTENCY_KEY_CONFLICT
Retention expiredTreated as a new requestTreated 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 PROCESSING is not a failure. Resubmitting it — under a new X-Idempotency-Key or 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.

  1. 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.
  2. Reuse that key on every retry of the operation.
  3. Back off exponentially, with a cap and jitter, honoring Retry-After as a floor.
  4. Retry on IDEMPOTENCY_KEY_PROCESSING; stop on IDEMPOTENCY_KEY_CONFLICT (you reused a key with a different body).
  5. 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

CodeHTTP StatusDescription
IDEMPOTENCY_KEY_REQUIRED400The X-Idempotency-Key header is missing on a partner-facing POST.
IDEMPOTENCY_KEY_INVALID400The key is not a canonical RFC 9562 UUID, or the request could not be fingerprinted.
IDEMPOTENCY_KEY_CONFLICT409The same key was reused with a different request body.
IDEMPOTENCY_KEY_PROCESSING409The 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-Key per operation; reuse it on retries, never across a different operation, and never after its record expires.
  • POST-only. A key on a non-POST request is ignored.
  • Always send Content-Type: application/json; an unfingerprintable request is rejected with 400 before any side effect.
  • A replay reflects submission-time state — read current state from the operation's status flow, not the replayed response.