> ## Documentation Index
> Fetch the complete documentation index at: https://public-openfx.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Idempotency-Key headers make retries safe: required on every write, TTLs from 30 minutes to 7 days, explicit responses for in-flight/mismatch, and the crash-recovery pattern to keep it safe across restarts.

All state-changing 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.

The TTLs, scoping rules, and recovery guidance below describe the FX endpoints. The [Payments endpoints](#payments-endpoints) follow the Payments API rules.

Sending a request without it returns **`428` <span class="ofx-enum">IDEMPOTENCY\_KEY\_MISSING</span>**. 428 (RFC 6585 Precondition Required) is the precise status for this: the header is a precondition, not a schema field.

<Note>
  **Related concepts** - [Resource IDs](/v3/resource-ids): server-generated
  permanent IDs vs client-generated short-lived idempotency keys -
  [Quickstart](/v3/quickstart): worked example of quote then trade with two
  distinct keys - [Errors](/v3/errors): `IDEMPOTENCY_*` error codes and recovery
  patterns
</Note>

## Endpoints

| Endpoint | TTL |
| - | - |
| [`POST /v3/fx/quotes`](/v3/api-reference/trade/create-quote) | 30 minutes |
| [`POST /v3/fx/trades`](/v3/api-reference/trade/execute-trade) | 24 hours |
| [`POST /v3/fx/trades/{id}/settle`](/v3/api-reference/trade/settle-trade) | 24 hours |
| [`POST /v3/fx/trades/{id}/reverse-quote`](/v3/api-reference/trade/generate-reverse-quote) | 30 minutes |
| [`POST /v3/fx/trades/{id}/reverse`](/v3/api-reference/trade/reverse-trade) | 24 hours |
| [`POST /v3/fx/withdrawals`](/v3/api-reference/trade-settlement/create-withdrawal) | 7 days |

<Note>
  **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.
</Note>

FX keys are scoped per organization + API key + endpoint. 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.

## Payments endpoints

Every `POST` 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 `422` <span class="ofx-enum">IDEMPOTENCY\_MISMATCH</span>. Reusing it while the first request is still executing returns `409` <span class="ofx-enum">IDEMPOTENCY\_IN\_FLIGHT</span>."

The key format and error codes on this page apply to both APIs. The Payments API owns whether it returns `Idempotency-Replayed`.

## Idempotency-key format

```http theme={"theme":{"light":"poimandres","dark":"poimandres"}}
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```

| Constraint | |
| - | - |
| Pattern | `^[a-zA-Z0-9_-]{1,255}$` |
| Recommended | UUID |
| Avoid | sequential IDs, raw timestamps, anything containing PII |

## How it works

For FX endpoints, the server stores `{key → response}` for the TTL. Subsequent requests with the same key:

| Scenario | Behavior |
| - | - |
| Same key, **same body** | Returns the cached response with the original status code |
| Same key, **different body** | Returns `422` <span class="ofx-enum">IDEMPOTENCY\_MISMATCH</span> |
| Same key, **original still PROCESSING** | Returns `409` <span class="ofx-enum">IDEMPOTENCY\_IN\_FLIGHT</span>; back off and retry with the same key |
| Same key, **TTL expired** | Treated as a brand-new request |

## Safe retry pattern

```javascript theme={"theme":{"light":"poimandres","dark":"poimandres"}}
import { randomUUID } from "node:crypto";

// `signRequest({ method, url, body })` returns { headers, body } with a freshly minted JWT
// AND a freshly computed X-Request-Signature each call. See the Authentication guide.
// See Rate limiting → Handling rate limits pattern (the canonical reference):
// https://docs.openfx.com/v3/rate-limiting#handling-rate-limits-pattern
//
// Two distinct lifetimes inside this function:
//   - Idempotency-Key: minted ONCE per logical operation; reused on every retry of that operation.
//   - JWT + signature: minted ONCE per HTTP attempt; never reused. JWT TTL is 60s and the X-Request-Signature is bound to the JWT's nonce, so both rotate together.
async function executeTradeWithRetry(quoteId, signRequest, maxAttempts = 3) {
  const idempotencyKey = randomUUID();
  const url = "https://api.openfx.com/v3/fx/trades";
  const body = JSON.stringify({ quoteId: quoteId });

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const signed = signRequest({ method: "POST", url, body });
    const res = await fetch(url, {
      method: "POST",
      headers: {
        ...signed.headers,
        "Idempotency-Key": idempotencyKey, // stable across attempts
      },
      body: signed.body,
    });

    if (res.ok) return res.json();

    const { error } = await res.json();

    // 409: the original is still PROCESSING. Wait and retry the same key.
    // Retry-After is seconds to wait, not an epoch timestamp.
    if (error.code === "IDEMPOTENCY_IN_FLIGHT") {
      const retryAfterHeader = res.headers.get("retry-after");
      const retryAfter =
        retryAfterHeader === null ? NaN : Number(retryAfterHeader);
      const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5000;
      await new Promise((r) => setTimeout(r, waitMs));
      continue;
    }

    throw error; // Anything else is terminal for this attempt loop
  }

  // Every attempt was IDEMPOTENCY_IN_FLIGHT.
  // Surface that to the caller instead of returning undefined.
  throw new Error("Retry budget exhausted");
}
```

## 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 <span class="ofx-enum">PENDING</span> rows on restart.

<Warning>
  **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.
</Warning>

<Steps>
  <Step title="Generate key, save it to DB">
    Mark the row `status: PENDING`. This must commit before the API call goes
    out.
  </Step>

  <Step title="Send the request">
    Standard call with the saved key in the `Idempotency-Key` header.
  </Step>

  <Step title="On success, mark the row complete">
    Store the response (the trade or withdrawal `id`, the executed amount,
    etc.).
  </Step>

  <Step title="On crash or timeout, leave the row PENDING">
    A recovery worker scans for <span class="ofx-enum">PENDING</span> 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.
  </Step>
</Steps>

```python theme={"theme":{"light":"poimandres","dark":"poimandres"}}
import json
import uuid
import requests

# `sign_request(method, url, body)` returns (headers, body_bytes) with a freshly minted JWT
# AND a freshly computed X-Request-Signature each call. See Authentication.
# `db` is your app's persistence layer

def execute_trade(quoteId, db, sign_request):
    # Save key FIRST, in case we crash before the API call returns
    idempotency_key = str(uuid.uuid4())
    db.save_pending_trade(quoteId=quoteId, idempotency_key=idempotency_key)

    url = 'https://api.openfx.com/v3/fx/trades'
    # Serialize once: the same bytes are signed and sent.
    body = json.dumps({'quoteId': quoteId}).encode('utf-8')
    headers, body_bytes = sign_request('POST', url, body)

    try:
        resp = requests.post(
            url,
            headers={**headers, 'Idempotency-Key': idempotency_key},
            data=body_bytes,
            timeout=30,
        )
        resp.raise_for_status()
        db.mark_completed(idempotency_key, resp.json())
        return resp.json()
    except requests.RequestException:
        # On restart, recovery loop will retry with the saved idempotency_key
        raise
```

### A persistence schema you can copy

A minimal table that supports the crash-recovery flow. Postgres-flavored; portable to any RDBMS.

```sql theme={"theme":{"light":"poimandres","dark":"poimandres"}}
CREATE TABLE idempotent_writes (
  key            TEXT PRIMARY KEY,         -- the Idempotency-Key sent to OpenFX
  endpoint       TEXT NOT NULL,            -- e.g. "POST /v3/fx/trades"
  request_body   JSONB NOT NULL,           -- exact body for replay
  status         TEXT NOT NULL,            -- "PENDING" | "SUCCEEDED" | "FAILED"
  resource_id    TEXT,                     -- populated on success (readable, typed-prefix ID, e.g. tde_5W7guYdHT24JFnRQrZN9y8)
  response_body  JSONB,                    -- cached for audit
  http_status    INTEGER,                  -- 2xx / 4xx / 5xx received
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at   TIMESTAMPTZ
);

-- Partial index keeps the recovery sweep O(PENDING rows) regardless of history.
CREATE INDEX idx_idem_pending ON idempotent_writes (created_at)
  WHERE status = 'PENDING';
```

<Note>
  **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.
</Note>

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 <span class="ofx-enum">PENDING</span> row, replays with the **same** `Idempotency-Key`. The server returns the cached response if the original landed, or executes once if it didn't.

```ts theme={"theme":{"light":"poimandres","dark":"poimandres"}}
// `signRequest({ method, url, body })` returns { headers, body } with a freshly minted JWT
// AND a freshly computed X-Request-Signature each call. See Authentication.
async function recoverPendingWrites(signRequest) {
  const stale = await db.idempotent_writes.findAll({
    where: { status: "PENDING", created_at: { lt: minutesAgo(2) } },
  });

  for (const row of stale) {
    const [method, path] = row.endpoint.split(" ");

    // Server TTLs: quotes and reverse-quotes 30min; trades, settle, and reverse 24h; withdrawals 7 days.
    // If we're past the window, alert and skip: replaying would create a NEW op.
    if (isPastTtl(path, row.created_at)) {
      await alert(`stale idempotent row past TTL: ${row.key}`);
      continue;
    }
    // isPastTtl: returns true when (now - createdAt) >= the endpoint's server TTL minus a safety margin.
    // function isPastTtl(path: string, createdAt: Date): boolean {
    //   const ttlMs =
    //     path.endsWith('/quotes') || path.endsWith('/reverse-quote') ? 25 * 60 * 1000 // 25min (server 30min)
    //   : path.endsWith('/trades') || path.endsWith('/settle') || path.endsWith('/reverse')
    //                                   ? 20 * 60 * 60 * 1000      // 20h    (server 24h)
    //   : path.endsWith('/withdrawals') ? 6 * 24 * 60 * 60 * 1000  // 6d     (server 7d)
    //   :                                 24 * 60 * 60 * 1000;
    //   return Date.now() - createdAt.getTime() >= ttlMs;
    // }

    const url = `https://api.openfx.com${path}`;
    const requestBody = JSON.stringify(row.request_body);
    // Re-sign on every replay: the JWT nonce is single-use and the signature is bound to it.
    const signed = signRequest({ method, url, body: requestBody });
    const res = await fetch(url, {
      method,
      headers: {
        ...signed.headers,
        "Idempotency-Key": row.key, // ← critical: same key
      },
      body: signed.body,
    });
    const body = await res.json();

    await db.idempotent_writes.update(row.key, {
      status: res.ok ? "SUCCEEDED" : "FAILED",
      resource_id: res.ok ? body.data.id : null,
      response_body: body,
      http_status: res.status,
      completed_at: new Date(),
    });
  }
}
```

### TTL alignment

Match your client-side retention to the server's TTL so your worker doesn't replay keys the server has forgotten:

| Endpoint | Server TTL | Worker should give up after |
| - | - | - |
| `POST /v3/fx/quotes` | 30 min | \~25 min |
| `POST /v3/fx/trades` | 24 h | \~20 h |
| `POST /v3/fx/trades/{id}/settle` | 24 h | \~20 h |
| `POST /v3/fx/trades/{id}/reverse-quote` | 30 min | \~25 min |
| `POST /v3/fx/trades/{id}/reverse` | 24 h | \~20 h |
| `POST /v3/fx/withdrawals` | 7 days | \~6 days |

If a row is past the worker cutoff and still <span class="ofx-enum">PENDING</span>, 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: true` indicates the response came from the idempotency cache. The original execution happened on a prior request with the same key.
* `Idempotency-Replayed: false` indicates the response is a fresh execution. The server processed the operation just now.

The header is absent on `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.

```http theme={"theme":{"light":"poimandres","dark":"poimandres"}}
HTTP/1.1 200 OK
Idempotency-Replayed: true
X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
Content-Type: application/json

{ ... cached response body ... }
```

### 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. On `settle`, `reverse`, and `reverse-quote`, reusing a key for a different trade ID returns `422` <span class="ofx-enum">IDEMPOTENCY\_MISMATCH</span>.

## 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):

<Steps>
  <Step title="Discover verified withdrawal accounts">
    Call [`GET /v3/fx/withdrawal-accounts`](/v3/api-reference/trade-settlement/list-withdrawal-accounts) to find your `withdrawalAccountId` values. The account `type` (fiat vs stablecoin) is inferred from this record; there's one withdrawal endpoint.
  </Step>

  <Step title="Save key, then POST">
    Persist `{ idempotency_key, withdrawalAccountId, amount, status: 'PENDING' }` to your DB, using the same schema as [above](#a-persistence-schema-you-can-copy). Then call [`POST /v3/fx/withdrawals`](/v3/api-reference/trade-settlement/create-withdrawal) with that key.
  </Step>

  <Step title="Poll until terminal">
    Poll [`GET /v3/fx/withdrawals/{id}`](/v3/api-reference/trade-settlement/get-withdrawal) until `status` reaches <span class="ofx-enum">COMPLETED</span> (see the [withdrawals](/v3/withdrawals)). For stablecoin withdrawals, `transactionHash` is populated once the transaction broadcasts.
  </Step>
</Steps>

```javascript theme={"theme":{"light":"poimandres","dark":"poimandres"}}
import { randomUUID } from "node:crypto";
// `signRequest({ method, url, body })` returns { headers, body } with a freshly minted JWT
// AND a freshly computed X-Request-Signature per call. `db` is your persistence layer.
// JWT TTL is 60s; this flow can run for minutes (poll loop) so the JWT + signature are minted
// per HTTP attempt, never captured once. See Rate limiting → Handling rate limits pattern:
// https://docs.openfx.com/v3/rate-limiting#handling-rate-limits-pattern

async function withdraw({
  withdrawalAccountId,
  amount,
  currency,
  signRequest,
}) {
  // 1. Save key first (same crash-safety reasoning as the recovery pattern above).
  const idempotencyKey = randomUUID();
  await db.savePendingWithdrawal({
    idempotencyKey,
    withdrawalAccountId,
    amount,
    currency,
  });

  // 2. Create. Verify the POST succeeded before polling, otherwise
  // withdrawal.id is undefined and the loop below silently misbehaves.
  const createUrl = "https://api.openfx.com/v3/fx/withdrawals";
  const createBodyBytes = JSON.stringify({
    withdrawalAccountId: withdrawalAccountId,
    withdrawalAmount: amount,
    currency,
  });
  const createSigned = signRequest({
    method: "POST",
    url: createUrl,
    body: createBodyBytes,
  });
  const createRes = await fetch(createUrl, {
    method: "POST",
    headers: { ...createSigned.headers, "Idempotency-Key": idempotencyKey },
    body: createSigned.body,
  });
  const createBody = await createRes.json();
  if (!createRes.ok) {
    // Leave the row in `PENDING` so the recovery worker can retry with the same key.
    const err = createBody.error;
    throw new Error(
      `API error: ${err.code} — ${err.message} (trace ${createRes.headers.get("x-trace-id")})`,
    );
  }
  // Both POST and GET wrap the resource in { data: ... }; unwrap it.
  let withdrawal = createBody.data;

  await db.markCreated(idempotencyKey, withdrawal.id);

  // 3. Poll until terminal. Mint per tick: even a 5s cadence exceeds the 60s JWT TTL
  // by the 13th iteration, and most withdrawals settle well after that.
  while (withdrawal.status === "PENDING") {
    await new Promise((r) => setTimeout(r, 5000));
    const pollUrl = `https://api.openfx.com/v3/fx/withdrawals/${withdrawal.id}`;
    const pollSigned = signRequest({ method: "GET", url: pollUrl });
    const pollBody = await fetch(pollUrl, { headers: pollSigned.headers }).then(
      (r) => r.json(),
    );
    withdrawal = pollBody.data;
  }

  return withdrawal; // terminal: COMPLETED | CANCELLED
}
```

<Tip>
  In production, prefer **webhooks** over polling for terminal status. Polling
  burns rate-limit budget and adds latency. See [Webhook
  authentication](/v3/webhooks/authentication) to wire up event delivery.
</Tip>

## 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` <span class="ofx-enum">IDEMPOTENCY\_MISMATCH</span>. One key per logical action.
* **Treating <span class="ofx-enum">IDEMPOTENCY\_KEY\_MISSING</span> 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 `/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

<CardGroup cols={2}>
  <Card title="Errors" icon="https://public-assets.openfx.com/docs/icons/errors.svg" href="/v3/errors#idempotency">
    Full code catalog and status mapping for idempotency errors.
  </Card>

  <Card title="Rate limiting" icon="https://public-assets.openfx.com/docs/icons/rate-limiting.svg" href="/v3/rate-limiting#handling-rate-limits-pattern">
    The broader retry-with-backoff strategy.
  </Card>

  <Card title="Live checklist" icon="https://public-assets.openfx.com/docs/icons/live-checklist.svg" href="/v3/live-checklist">
    Crash-recovery and key-persistence checks for go-live.
  </Card>

  <Card title="Withdrawals" icon="https://public-assets.openfx.com/docs/icons/withdrawals.svg" href="/v3/withdrawals">
    Per-state recovery across the 7-day idempotency window.
  </Card>
</CardGroup>
