Skip to main content

Idempotency

tip

Stable.

How to retry safely without double-spending nonces or duplicating orders.

TL;DR

  • Every action has a nonce. Reusing one returns NONCE_REPLAYED at HTTP 200.
  • Set a unique cloid on every Order / ModifyOrder; the server rejects duplicate cloid on 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

OutcomeNonce consumed?Safe to retry?
202 admittedYESNO — duplicate effect
200 NONCE_REPLAYEDNO (already past it)NO — re-sign at a higher nonce
400 action: <parse error> / other parse errorsNOYES — fix and resubmit at same nonce
401 signer_*NONO until the signing issue is fixed; the nonce is unconsumed
422 reduce_only_violation and other admit-time logical errorsNOYES once the logical issue is fixed
429 rate limit exceededNOYES after a client-side backoff — the body carries no retry hint
503 gateway overloadedNOYES after a client-side backoff
Network drop (no response)UNKNOWNRECONCILE — 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.

warning

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

ActionIdempotent?Why
CancelyesCancelling a non-existent / already-cancelled order is refused with ORDER_NOT_FOUND — harmless
CancelByCloidyesSame
UpdateLeverageyesSetting leverage to the current value is a no-op
UpdateMarginModeyesSame
UserPortfolioMarginyesSame
ApproveAgentyesSame approval data overwrites the existing record
UsdcTransferNOTransfers a fresh amount each time
WithdrawUsdcNOSame
Delegate / UndelegateNOAdd 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

SymptomCauseFix
NONCE_REPLAYED on every requestLocal clock skew (using Date.now()), or one past action signed far in the futureSync 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 nonceSharing the same accountUse a shared nonce service, or one script per account
NONCE_REPLAYED after a reconnectLocal nonce counter reset to pre-drop valuePersist 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

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.