Idempotency
Stable.
How to retry safely without double-spending nonces or duplicating orders.
TL;DR
- Every action has a
nonce. Reusing one returnsNONCE_REPLAYEDat HTTP200. - Set a unique
cloidon everyOrder/ModifyOrder; the server rejects duplicatecloidon the same account, so retry is safe. - For non-order actions, the state machine is naturally idempotent (cancel of a non-existent order is harmless; transfer is enforced by balance check).
- The network error model splits into three classes — admission rejection, commit-time error, network drop — each with a different retry rule.
Three error classes
Nonce consumption
| Outcome | Nonce consumed? | Safe to retry? |
|---|---|---|
202 admitted | YES | NO — duplicate effect |
200 NONCE_REPLAYED | NO (already past it) | NO — re-sign at a higher nonce |
400 action: <parse error> / other parse errors | NO | YES — fix and resubmit at same nonce |
401 signer_* | NO | NO until the signing issue is fixed; the nonce is unconsumed |
422 reduce_only_violation and other admit-time logical errors | NO | YES once the logical issue is fixed |
429 rate limit exceeded | NO | YES after a client-side backoff — the body carries no retry hint |
503 gateway overloaded | NO | YES after a client-side backoff |
| Network drop (no response) | UNKNOWN | RECONCILE — see reconcile after drop below |
The rule: a request gets a server response → the nonce decision is made. A network drop is the only ambiguous case.
There is no nonce_must_increase and no nonce_too_small. Neither string
exists on this API, and neither answer is a 400. A replayed nonce answers
NONCE_REPLAYED at HTTP 200, because the
refusal comes from the block builder and not from admission. Not live yet:
a live node drops the replay in silence, so the caller waits out the order
window and the gateway answers a 502. Branch on the code, never on a 502
body.
Strategy: cloid
For order placement, the client order id is the strongest dedup primitive.
const cloid = '0x' + crypto.randomBytes(16).toString('hex');
await client.submitOrderNative({
owner, market: 0, side: 'bid', kind: 'limit',
size: 1_000, limit_px: 5_000_000_000_000,
tif: 'gtc', stp_mode: 'cancel_newest', reduce_only: false,
cloid,
});
The server returns:
| Server response | What it means |
|---|---|
{"resting":{"oid":N,"cloid":"0x..."}} | Order placed, dedup confirmed |
{"error":{"code":"ORDER_DUPLICATE_CLOID", …}} | A prior request with the same cloid was admitted; the order is already on the book. Look it up by cloid |
{"error":{"code":"<other>", …}} | This entry failed; you can retry with a fresh cloid or the same one. Match on code, never on message |
Retry rule for orders: same cloid + same params is idempotent end-to-end. If the first try landed, the second sees duplicate cloid and you know the original is in place.
The same logic applies to ModifyOrder — set a new cloid for the modify, dedup the modify.
Strategy: state-machine idempotence
Most non-order actions are idempotent at the state-machine level:
| Action | Idempotent? | Why |
|---|---|---|
Cancel | yes | Cancelling a non-existent / already-cancelled order is refused with ORDER_NOT_FOUND — harmless |
CancelByCloid | yes | Same |
UpdateLeverage | yes | Setting leverage to the current value is a no-op |
UpdateMarginMode | yes | Same |
UserPortfolioMargin | yes | Same |
ApproveAgent | yes | Same approval data overwrites the existing record |
UsdcTransfer | NO | Transfers a fresh amount each time |
WithdrawUsdc | NO | Same |
Delegate / Undelegate | NO | Add to the action queue each call |
For NOT-idempotent actions, use either:
- The nonce as your dedup key: track which nonces you've submitted, never submit twice with the same nonce. The server enforces this regardless.
- An external dedup table: keep a
{request_id → nonce}map; if your retry sees an existing nonce for this request_id, you've already submitted.
Reconcile after network drop
When the response is lost (TCP closed, timeout, etc.) you don't know if the action committed. Reconcile:
For orders
Query by cloid:
curl -X POST $BASE/info \
-d '{"type":"open_orders","address":"0x..."}' | jq '.[] | select(.cloid == "0x<cloid>")'
If present → admitted; treat as success.
If absent → check user_fills for a fill against that cloid.
If still absent → admission failed (or was evicted from mempool). Submit again with the same cloid.
For transfers / withdrawals
There is no per-action commit lookup for non-order actions. Reconcile from
the resulting state instead: check the
ledger_updates on-subscribe
snapshot (the most recent 100 records for the account) for a matching
record, or diff account_state across the drop — its spot.balances array carries every spot token.
action_hash is deterministic and computable locally, but it is not
echoed on any WS event or /info read today — it is only useful as the
correlation key in the synchronous /exchange response you already have, not
for a post-hoc lookup.
// action_hash = keccak256(action_json ‖ owner(20) ‖ nonce(8, big-endian))
// `action_json` is the RAW bytes of the `action` field you posted. Hash the
// exact string — re-serializing reorders keys and changes the hash.
// `owner` is the resolved account, not the signing agent.
const actionHash = keccak256(concat(utf8(actionJson), ownerAddr, nonceBE8(nonce)));
// Useful to log alongside the synchronous admission response for your own
// audit trail — not for matching against a later WS event or info query.
If you can't determine outcome:
- For an idempotent action: retry safely (use a fresh nonce, since the old one may already be consumed).
- For a non-idempotent action: pause; query the account state to see if the side-effect happened; resume only after certainty.
Sequence — retry with cloid after timeout
The cloid + the server-side checks make the retry safe even when the network is unreliable.
Nonce-issue troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
NONCE_REPLAYED on every request | Local clock skew (using Date.now()), or one past action signed far in the future | Sync the clock, or use a monotonic counter. The window anchors on the HIGHEST nonce ever committed, so sign above that anchor |
| Two scripts collide on nonce | Sharing the same account | Use a shared nonce service, or one script per account |
NONCE_REPLAYED after a reconnect | Local nonce counter reset to pre-drop value | Persist last-submitted nonce across restarts |
Complement: action expiry
The nonce window stops an action from committing twice; it does not stop a
still-unused signature from committing late. The optional
action expiresAfter closes
that gap — sign an expiry into the action and it is rejected once consensus time
passes it, so a leaked or relay-held signature cannot land after its window. The
two are complementary: nonce guards against duplication, expiresAfter guards
against staleness. It is optional and defaults to off (0 / absent), which keeps
the digest byte-for-byte unchanged.
See also
POST /exchange— full envelope includingnonceand the optionalexpires_after- Errors — every error string + remediation
- Error handling — admission vs commit vs network decision tree
- Rate limits — pace your retries
FAQ
Show FAQ
Q: Should I use Date.now() or a counter?
A: Date.now() is fine for single-instance clients. For multi-instance clients on one account, use a shared monotonic counter (Redis INCR, e.g.) so two instances don't collide.
Q: What if I want to deliberately replay an action (idempotent flow)?
A: Use the same cloid (for orders) and a fresh nonce. The server enforces dedup via cloid; the nonce just keeps the wire intact.
Q: Are cloids reusable after the original order is cancelled / filled? A: No. Cloids are globally unique per account, forever. Use a fresh one for every order.
Q: Does the WS feed give me commit-time confirmation I can use for reconcile?
A: Yes, for orders. Subscribe to order_updates or fills and match on cloid — neither channel carries action_hash. The WS feed is the recommended way to confirm commit state during retry.