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 clientReference on the Intent body. clientReference is an opaque label you attach for your own correlation and the gateway echoes it back. It is never the deduplication key — only X-Idempotency-Key decides whether a request is a retry — but it is part of the request body and therefore covered by the body fingerprint. Changing it while reusing a key makes the request a different one, which conflicts. Use X-Idempotency-Key to make a retry safe, and clientReference 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/intents
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 HTTP method and the request path, taken literally. A different path — including a different API version prefix — is an independent record, so reusing a key across versioned paths does not conflict and does not replay.

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 — a one-way hash, not a stored copy of the body, isolated per principal. The fingerprint is computed as:

SHA-256( HTTP method + path and query + request body )

The body is hashed as the raw bytes you sent — it is not canonicalized. Object keys are not sorted, whitespace is not stripped, and no normalization of any kind is applied. Two requests with identical meaning but different serialization (a different field order, added whitespace, a re-encoded number) produce different fingerprints and are treated as different requests.

Retry with the exact bytes of the original request. Keep the serialized request body as a string and resend that string; re-serializing an object at retry time can change the bytes — different JSON libraries order keys and format values differently — and the retry will conflict instead of replaying.

String values are likewise 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 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

{
  "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 24 hours from when the request was accepted — not from when its response was returned. The expiry is fixed when the record is created and is not extended on completion, so size your retry window from the moment you send the request. 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 submitIntent(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/intents', {
      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.