Skip to main content
All state-changing v3 requests (POST) require an 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 patterns

Endpoints

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.
Keys are scoped per endpoint + per org. You can safely reuse the same UUID across /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
Don’t:
  • 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.
Persist the idempotency key to your DB before sending the request, not after. A new key on a restart-retry is effectively a brand-new operation, and the server will execute it again. With withdrawals, that means money out the door twice.
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.
Retention rule: keep rows for at least 2× the server TTL of the endpoint they target so audits can reconcile after a long incident. Past the server’s window, replays no longer match a cached response and will execute a new operation — guard against that in your worker.

The recovery worker

Runs on every service startup and periodically (e.g. every 60s). For each PENDING 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.
The header is always present on responses from these three endpoints — value flips between 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-Id always identifies the current request hop (so debugger logs land on the right span); X-Request-Timestamp reflects when the replay was served; rate-limit headers reflect current budget.
  • Idempotency-Replayed: set per the rule above.
This means reconciliation tooling that hashes the response body across retries gets stable hashes within the TTL — useful for audit trails and replay-safe ETL.

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.
In production, prefer webhooks over polling for terminal status. Polling burns rate-limit budget and adds latency. See Webhook authentication to wire up event delivery.

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_MISSING as 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 /quotes and /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.