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

# Idempotency

> Idempotency-Key headers make retries safe: required on every v3 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 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](/v3/migration-from-v2)): 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](/v2/migration-v1-to-v2#step-5-handle-new-error-codes)). The new 428 (RFC 6585 Precondition Required) is more precise: 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/withdrawals`](/v3/api-reference/trade-settlement/create-withdrawal) | 7 days     |

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

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

```http theme={null}
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

The server stores `{key → response}` for the TTL. Subsequent requests with the same key:

| Scenario                                | Behaviour                                                                 |
| --------------------------------------- | ------------------------------------------------------------------------- |
| Same key, **same body**                 | Returns the cached response with the original status code                 |
| Same key, **different body**            | Returns `422 IDEMPOTENCY_MISMATCH`                                        |
| Same key, **original still PROCESSING** | Returns `409 IDEMPOTENCY_IN_FLIGHT`; back off and retry with the same key |
| Same key, **TTL expired**               | Treated as a brand-new request                                            |

## Safe retry pattern

```javascript theme={null}
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 `PENDING` 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 `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.
  </Step>
</Steps>

```python theme={null}
import uuid
import requests

# `jwt` is a freshly-minted bearer token. See Authentication.
# `db` is your app's persistence layer

def execute_trade(quoteId, db):
    # 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)

    try:
        resp = requests.post(
            'https://api.openfx.com/v3/fx/trades',
            headers={
                'Authorization': f'Bearer {jwt}',
                'Idempotency-Key': idempotency_key,
            },
            json={'quoteId': quoteId},
            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={null}
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"
  resourceId    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
  createdAt     TIMESTAMPTZ NOT NULL DEFAULT now(),
  completedAt   TIMESTAMPTZ
);

-- Partial index keeps the recovery sweep O(PENDING rows) regardless of history.
CREATE INDEX idx_idem_pending ON idempotent_writes (createdAt)
  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 `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.

```ts theme={null}
async function recoverPendingWrites() {
  const stale = await db.idempotent_writes.findAll({
    where: { status: "PENDING", createdAt: { lt: minutesAgo(2) } },
  });

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

    // Server TTLs: quotes 30min, trades 24h, withdrawals 7 days.
    // If we're past the window, alert and skip — replaying would create a NEW op.
    if (isPastTtl(path, row.createdAt)) {
      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')      ? 25 * 60 * 1000           // 25min  (server 30min)
    //   : path.endsWith('/trades')      ? 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 res = await fetch(`https://api.openfx.com${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${freshJwt()}`,
        "Idempotency-Key": row.key, // ← critical: same key
        "Content-Type": "application/json",
      },
      body: JSON.stringify(row.request_body),
    });
    const body = await res.json();

    await db.idempotent_writes.update(row.key, {
      status: res.ok ? "succeeded" : "FAILED",
      resourceId: res.ok ? body.id : null,
      response_body: body,
      http_status: res.status,
      completedAt: 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/withdrawals` | 7 days     | \~6 days                    |

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).

```http theme={null}
HTTP/1.1 201 Created
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.

## 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 — 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 `COMPLETED` (see the [withdrawal lifecycle](/v3/withdrawal-lifecycle)). For stablecoin withdrawals, `transactionHash` is populated once the transaction broadcasts.
  </Step>
</Steps>

```javascript theme={null}
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")})`,
    );
  }
  let withdrawal = createBody;

  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" ||
    withdrawal.status === "PROCESSING"
  ) {
    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 });
    withdrawal = await fetch(pollUrl, { headers: pollSigned.headers }).then(
      (r) => r.json(),
    );
  }

  return withdrawal; // terminal: COMPLETED | FAILED | CANCELED | RETURNED
}
```

<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 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

<CardGroup cols={2}>
  <Card title="Errors" icon="triangle-exclamation" href="/v3/errors#idempotency">
    Full code catalog and status mapping for idempotency errors.
  </Card>

  <Card title="Rate limiting" icon="gauge-high" href="/v3/rate-limiting#handling-rate-limits-pattern">
    The broader retry-with-backoff strategy.
  </Card>

  <Card title="Live checklist" icon="list-check" href="/v3/live-checklist">
    Crash-recovery and key-persistence checks for go-live.
  </Card>

  <Card title="Withdrawal lifecycle" icon="arrow-up-from-bracket" href="/v3/withdrawal-lifecycle">
    Per-state recovery across the 7-day idempotency window.
  </Card>
</CardGroup>
