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.
This is the same mechanism as v2 (you can migrate retry code unchanged): v2 already required Idempotency-Key on POST endpoints, and v3 keeps that requirement while changing the missing-header response and replay semantics. Sending a request without it returns 428 IDEMPOTENCY_KEY_MISSING: note the status change from v2, which returned 400 IDEMPOTENCY_KEY_REQUIRED (see v2 migration step 5). The new 428 (RFC 6585 Precondition Required) is more precise: 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 duplicates
are cheap (re-quote at the worst), so 30 minutes is plenty. Trade 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. All three TTLs match v2, so retry code can migrate
unchanged.
/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.
Idempotency-key format
How it works
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 replaysPENDING 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 eachPENDING row, replays with the same Idempotency-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
Every response from an idempotent endpoint (POST /v3/fx/quotes, POST /v3/fx/trades, POST /v3/fx/withdrawals) carries an Idempotency-Replayed header:
Idempotency-Replayed: true— this response came from the idempotency cache. The original execution happened on a prior request with the same key.Idempotency-Replayed: false— this response is a fresh execution. The server processed the operation just now.
true and false. It is not emitted on GET / list endpoints (which don’t have idempotency semantics).
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.
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 — 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 withdrawal lifecycle). 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
422 IDEMPOTENCY_MISMATCH. One key per logical action. - Treating
IDEMPOTENCY_KEY_MISSINGas a server bug. It’s a client bug. v3 requires the header 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.
Withdrawal lifecycle
Per-state recovery across the 7-day idempotency window.