Skip to content

Idempotency and retries

Every write on this API requires an Idempotency-Key, and the rules for what a repeated key does are the most important thing on this page. Read it before you write your retry logic, not after.

Early access

The write endpoints are not enabled on production. See the changelog for what is available today.

The header is mandatory

Idempotency-Key: 7f9c2e14-3b6d-4a51-9f0e-2c8d51a7b3e4

Send it on all nine write endpoints. It is a string of 1 to 255 characters; leading and trailing spaces are stripped. Anything missing, blank, or longer is refused as 400 idempotency_key_required, before the rest of the request is looked at.

The query, account-status, token and webhook-management endpoints do not take it. They are POST too, but nothing they do needs protecting from a repeat.

A random UUID per logical operation is the right choice. The key must come from the operation, not from the attempt — if you generate a fresh key on each retry, you have turned retries off.

What a key is scoped to

A key is not global. It identifies one operation within a subject, and the subject is different per endpoint:

Endpoint Subject the key is scoped to
POST /api/v1/accounts client_reference_id from the body
POST /api/v1/transactions client_reference_id from the body
POST /api/v1/accounts/bulk The rows you submitted — each row's reference, email and brand, in order
POST /api/v1/positions/open trading_account_id from the body
POST /api/v1/positions/close trading_account_id from the body
PATCH /api/v1/accounts/{id} The account
POST /api/v1/transactions/{id}/approve The account, from account_id
POST /api/v1/transactions/{id}/reject The account, from account_id
POST /api/v1/transactions/{id}/cancel The account, from account_id

Keys are also scoped to your credential, so two credentials in the same firm never collide, and the subjects above never collide with each other. You can reuse the same key string on a create and on a trade without either one seeing the other.

Repeating a key

Send the same key with the same subject and you get the original response back: the same status and the same body, byte for byte. Nothing runs a second time.

That includes refusals. If the first attempt was rejected with a 4xx, the rejection is what replays — the same 409, the same 422, with the same detail.

A replay carries two different correlation ids

The X-Request-Id header identifies the call you just made. The request_id inside a replayed body is the one from the original attempt, because the body is returned exactly as it was first produced. That is useful, not a bug: it is the fastest way to tell a replay from a first execution in your own logs. Quote the header when you raise a support request.

Server-side failures are treated differently on purpose. A 429, a 503 write_contention, or a 500 is not recorded as the answer, so retrying with the same key genuinely re-runs the operation. This is the case retries exist for, and it is safe: the work either already completed (and you get that result) or it did not (and it runs once now).

Records of past keys are kept for 24 hours. A repeat after that window executes again rather than replaying, so do not use the key as a long-term deduplication store — it protects a retry loop, not a nightly reconciliation.

The request body is not compared

Repeating a key checks the key and the subject. It does not check that the body matches the first request.

So POST /api/v1/positions/open with the same key and the same trading_account_id but a different instrument, side, or size returns the first order and silently ignores everything you just sent. No error is raised.

If you have integrated with an API that refuses a reused key when the payload differs, this one does not do that. One key, one operation — never recycle a key across operations, and never let a "retry" pick up new values on the way.

409 idempotency_in_flight

An earlier attempt with the same key is still running. Do not hammer it: wait a second or so and repeat the identical request. The original attempt either finishes — and your repeat replays its result — or it is abandoned and your repeat takes over. Attempts are never abandoned in under about 30 seconds, so a backoff of a few seconds is right.

After a refusal you have fixed

Because a 4xx replays, correcting the request and resending it with the same key gives you the old refusal, not a fresh attempt. A corrected request is a new operation:

  1. Fix the body.
  2. Generate a new Idempotency-Key.
  3. Send it.

Creating: the reference id decides identity

For POST /api/v1/accounts and POST /api/v1/transactions, the subject is your own client_reference_id. Two consequences follow, and they cut in opposite directions.

Same reference, different key, still one account. The account is derived from client_reference_id, so even a retry that lost its key and generated a new one converges on the same account instead of creating a second one. Matching is case-insensitive: ACME-8891 and acme-8891 are the same reference.

Different reference means a different account — even with the same key. Changing client_reference_id changes the subject, so the key no longer matches anything and the request executes as a brand-new create. This is the mistake that bites hardest: a retry loop that regenerates the reference id alongside the payload will create duplicate people, one per attempt.

Two things save you from the worst version of that, and neither is a substitute for a stable reference:

  • The email and phone number on the request are checked against the brand. A second account for someone who already exists there is refused with 409 invalid_state rather than created.
  • That refusal is itself recorded, so retrying the same request replays the 409 instead of re-running the check.

Pick client_reference_id from your own primary key for that customer, once, and never regenerate it.

Compare-and-swap on updates

PATCH /api/v1/accounts/{id} additionally requires If-Match, which is a separate protection from the idempotency key: the key stops a duplicate write, If-Match stops a stale one.

Every account response carries a strong ETag, and the same value appears in the body as revision:

ETag: "48815"

Send it back on the update:

curl -sS -X PATCH "$BASE/api/v1/accounts/4210001" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Idempotency-Key: 0f2a...' \
  -H 'If-Match: "48815"' \
  -H 'Content-Type: application/json' \
  -d '{"status": "Contacted"}'
Answer Meaning What to do
200 Applied. The response carries the new ETag. Keep the new tag for the next update.
412 revision_conflict Something changed the account since you read it. Read it again, re-apply your change on the fresh copy, and send again with a new key.
404 not_found The account does not exist, or your credential cannot reach it. Nothing to retry.
422 validation_failed The body changes nothing, or If-Match is malformed. Fix and resend with a new key.

Visibility is always checked first, so an account you cannot reach answers 404 even when the ETag you sent is stale. A 412 therefore only ever comes from an account you really can see.

Do not blind-retry a 412

Replaying the identical request cannot succeed — the tag is still stale. Re-read, re-apply, and send a new key. The SDKs expose this as a read-modify-write helper rather than a retry.

What to retry, at a glance

Response Retry? With the same key?
409 idempotency_in_flight Yes, after a short wait Yes — that is the point
429 rate_limited Yes, honoring Retry-After Yes
503 write_contention Yes Yes
500 internal_error Yes Yes
409 invalid_state No — the state refuses it
412 revision_conflict Re-read first, then send as a new operation No, new key
422 validation_failed Fix first, then send as a new operation No, new key
403 / 404 No
501 not_implemented No. It will fail identically