Idempotency-Key header. The value is a client-generated unique string that tags one logical operation. If a request times out, fails partway, or your service crashes mid-call, retry with the same key: the server returns the cached result instead of executing the operation twice.
The TTLs, scoping rules, and recovery guidance below describe the FX endpoints. The Payments endpoints follow the Payments API rules.
Sending a request without it returns 428 IDEMPOTENCY_KEY_MISSING. 428 (RFC 6585 Precondition Required) is the precise status for this: the header is a precondition, not a schema field.
Related concepts - Resource IDs: server-generated
permanent IDs vs client-generated short-lived idempotency keys -
Quickstart: worked example of quote then trade with two
distinct keys - Errors:
IDEMPOTENCY_* error codes and recovery
patternsEndpoints
TTLs are sized by the retry window each operation needs. Quote and
reverse-quote duplicates are cheap (re-quote at the worst), so 30 minutes is
plenty. Trade, settle, and reverse duplicates can move thousands of dollars;
24 hours covers most retry patterns without long-tail key sprawl. Withdrawal rails are long-running by nature (multi-day
SWIFT, weekend ACH cutoffs, end-of-month batch reconciliation), so the window
is wider at 7 days.
/v3/fx/quotes and /v3/fx/trades without conflict, but doing so generally adds confusion rather than convenience. Generate one key per logical operation and let the scoping take care of itself.
Payments endpoints
EveryPOST in the Payments API (/v3/common/accounts and /v3/payments/...) also requires Idempotency-Key. The Payments API owns its idempotency behavior; the FX TTLs above do not apply, and this guide does not specify a Payments key lifetime. Per the Payments API:
- “A key is scoped to the API key, the endpoint, and the organization together, and it is case-sensitive.”
- “Replaying the identical request returns the stored outcome. Reusing the key with a different request body returns
422IDEMPOTENCY_MISMATCH. Reusing it while the first request is still executing returns409IDEMPOTENCY_IN_FLIGHT.”
Idempotency-Replayed.
Idempotency-key format
How it works
For FX endpoints, the server stores{key → response} for the TTL. Subsequent requests with the same key:
Safe retry pattern
How to generate keys
Do:- Generate the key once per logical operation, not once per HTTP request
- Use unique identifier
- Store the key alongside the operation in your DB if you need to reconcile after a crash
- Generate a new key on retry (defeats the entire mechanism)
- Reuse the same key for unrelated operations (trade A vs trade B)
- Use predictable values (timestamps, counters)
- Embed user IDs, emails, or other PII
Crash recovery
The safe retry pattern above covers retries within one process. If your service crashes mid-call, the request may have reached OpenFX even though your client never got a response. Surviving that needs one more piece: persist the key before you send it, and a worker that replays PENDING rows on restart.1
Generate key, save it to DB
Mark the row
status: PENDING. This must commit before the API call goes
out.2
Send the request
Standard call with the saved key in the
Idempotency-Key header.3
On success, mark the row complete
Store the response (the trade or withdrawal
id, the executed amount,
etc.).4
On crash or timeout, leave the row PENDING
A recovery worker scans for PENDING rows and re-fires the request with the
same key. The server returns the cached response if the original landed, or
executes it for the first time if it didn’t.
A persistence schema you can copy
A minimal table that supports the crash-recovery flow. Postgres-flavored; portable to any RDBMS.Concurrency: the recovery worker and the original request can both try to
update the same row. Use either
SELECT … FOR UPDATE SKIP LOCKED when reading
PENDING rows, or a WHERE status = 'PENDING' predicate on the UPDATE (so a
winning writer’s update fails as zero rows affected on the loser’s path). If
you run more than one recovery worker, hold an advisory lock around the sweep
(pg_try_advisory_lock(hashtext('idem-recovery'))), or run the worker as a
leader-elected singleton.The recovery worker
Runs on every service startup and periodically (e.g. every 60s). For each PENDING row, replays with the sameIdempotency-Key. The server returns the cached response if the original landed, or executes once if it didn’t.
TTL alignment
Match your client-side retention to the server’s TTL so your worker doesn’t replay keys the server has forgotten:
If a row is past the worker cutoff and still PENDING, page a human: it’s a real reconciliation question, not a retry.
How to tell a replay from a fresh execution
For FX responses,Idempotency-Replayed is present only when the response passes through idempotency handling:
Idempotency-Replayed: trueindicates the response came from the idempotency cache. The original execution happened on a prior request with the same key.Idempotency-Replayed: falseindicates the response is a fresh execution. The server processed the operation just now.
401, 403, and 429 responses; gateway 400 and 413 responses; request-validation 400 responses; and idempotency errors. It is also absent on GET/list endpoints.
What’s cached and what’s fresh on a replay
- Body and HTTP status code: byte-identical to the original. v3 returns the stored bytes verbatim: the cache doesn’t re-serialize through the current schema. If a minor version bump later adds an additive field, replays of pre-bump records will not include the new field (only fresh executions do).
- Observability headers (
X-Trace-Id,X-Request-Timestamp,RateLimit-*): regenerated per attempt.X-Trace-Idalways identifies the current request hop (so debugger logs land on the right span);X-Request-Timestampreflects when the replay was served; rate-limit headers reflect current budget. Idempotency-Replayed: set per the rule above.
settle, reverse, and reverse-quote, reusing a key for a different trade ID returns 422 IDEMPOTENCY_MISMATCH.
Sandbox vs live keys never collide
Idempotency keys are isolated by environment. A key used in Sandbox cannot replay against Live, and vice versa. You can use the same UUID in Sandbox testing and a Live integration without cross-environment replay.Putting it together: the withdrawal flow
Withdrawals have the highest cost-of-mistake, so they’re the canonical example of the full pattern (persisted key, crash-safe send, poll to terminal):1
Discover verified withdrawal accounts
Call
GET /v3/fx/withdrawal-accounts to find your withdrawalAccountId values. The account type (fiat vs stablecoin) is inferred from this record; there’s one withdrawal endpoint.2
Save key, then POST
Persist
{ idempotency_key, withdrawalAccountId, amount, status: 'PENDING' } to your DB, using the same schema as above. Then call POST /v3/fx/withdrawals with that key.3
Poll until terminal
Poll
GET /v3/fx/withdrawals/{id} until status reaches COMPLETED (see the withdrawals). For stablecoin withdrawals, transactionHash is populated once the transaction broadcasts.Common mistakes
- Generating a new key on every HTTP retry. Defeats the entire mechanism. Generate the key once per logical operation, reuse it on every retry of that operation.
- Generating a new key after a restart. Same problem. The server treats it as a new operation. Persist the key to disk before sending the request.
- Reusing the same key for two genuinely different operations. Returns
422IDEMPOTENCY_MISMATCH. One key per logical action. - Treating IDEMPOTENCY_KEY_MISSING as a server bug. It’s a client bug. The header is required on every write; missing it returns 428.
- Embedding PII in the key. The key is logged. Use UUID v4 or another opaque random string. Never email, user ID, or any identifier that lets an observer infer the action.
- Using the same key across
/quotesand/trades. Technically safe (keys are scoped per endpoint), but conceptually muddled. The quote and the trade are two operations. Two keys, one per operation.
What’s next
Errors
Full code catalog and status mapping for idempotency errors.
Rate limiting
The broader retry-with-backoff strategy.
Live checklist
Crash-recovery and key-persistence checks for go-live.
Withdrawals
Per-state recovery across the 7-day idempotency window.