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:
- Fix the body.
- Generate a new
Idempotency-Key. - 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_staterather than created. - That refusal is itself recorded, so retrying the same request replays the
409instead 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 | — |