- Per-IP weight limits — every REST request (and WebSocket message) costs a weight. Each source IP gets a token budget that refills over time; expensive endpoints drain it faster.
- Per-subaccount trading limits — order placement and cancellation draw from two pools keyed to your subaccount, independent of IP. These scale with your realized trading volume.
Limits are enforced globally across all gateway nodes (state lives in a shared store), so spreading requests across connections or hitting different nodes does not raise your effective budget.
Per-IP weight limits
Every IP gets a token bucket holding 1,500 weight, refilling continuously at 1,500 weight per minute (25 weight/second). Each request deducts its weight before the handler runs. When the bucket can’t cover a request’s weight, the request is rejected.Rejection contract
A throttled request returns:Retry-After is the whole number of seconds to wait before the bucket holds enough tokens to retry (minimum 1). Arcus does not return X-RateLimit-* headers — the budget and per-endpoint weights are published here instead, so you can account for cost client-side rather than discovering it per response.
Which limit did I hit?
The response body tells you which bucket rejected the request, so you never have to guess which layer to tune:
Order-write rejections carry two extra fields:
retryAfterMs, the precise millisecond wait (the Retry-After header is the same value rounded up to whole seconds, so it is never shorter), and the clientId / clientIds you submitted, echoed back so a rejection can be correlated to a specific order:
There is no maker-specific or taker-specific throttle. The only limits that exist are the per-IP weight bucket, the two per-subaccount trading pools, and the WebSocket connection limits below. If a
429 is not attributable to one of those, it is not a rate limit.Working out which limit binds first
Because the IP bucket refills at 25 weight/second, the sustainable request rate depends entirely on endpoint weight:
A polling loop at, say, 4 req/s against weight-20 endpoints costs 80 weight/s against a 25/s refill — it drains the 1,500 bucket in roughly 25 seconds and then throttles continuously, even though the order path is completely idle. Order writes cost 0 IP weight, so a
429 seen while placing orders at a modest rate is usually the read traffic sharing that IP, not the trading path. Check the reason field before rate-limiting your own order flow.
Weight tiers
Endpoints fall into a handful of tiers by how much work they cost the backend. With a 1,500/min budget that’s roughly 750 cheap reads, 75 standard list calls, or 12setLeverage/withdraw calls per minute from a single IP.
Batch order writes (
batchPlaceOrders, batchCancelOrders) are also free on the IP base; they incur only a post-flight per-item charge of floor(N/40) weight for a batch of N — so a batch of up to 39 orders costs 0 IP weight. As with single writes, the real limit is the per-subaccount trading pool (each element charges the pool 1).
List endpoints add a per-item charge on top of their base weight, computed from the rows actually returned: weight = base + floor(items / N), where N is 20 for most lists — 60 for candles and 50 for openOrders. Page sizes are capped at 1,000 rows for most lists — 1,500 for candles — so the worst-case add-on is bounded: a full 1,000-row page of fills costs 20 + 50 = 70, and a full 1,500-bar candles page costs 20 + 25 = 45. l2OrderBook works the same way on order-book depth, charging on the depth you request rather than the rows returned: 2 + floor(nLevels/20), maxing at weight 7 for a 100-level book. Because the row count isn’t known until the query runs, this charge is applied after the response is sent, so a single large page can briefly drive your bucket negative; requests arriving before it refills are rejected with 429 and a Retry-After hint, as above.
Per-subaccount trading limits
Order placement and cancellation draw from two pools scoped to your subaccount (address + account index), enforced on both the REST and WebSocket order paths. This is the address-based analogue to the per-IP layer: it bounds how fast a single account can act, regardless of how many IPs or connections it spreads across.
The pools are independent: a cancel-heavy strategy can drain the cancel pool while the order pool sits nearly full, or vice versa.
Both pools are keyed strictly on
(address, accountIndex) — there is no per-market component. Orders across every market share one order pool, so spreading flow over more markets does not buy extra headroom; moving it to another subaccount does, since each index carries its own pools (and its own volume credit).
Volume-based replenishment
Each pool’s effective cap grows with your lifetime realized notional — the more you trade, the more headroom you get:When a pool is empty
Past the effective cap, a slow drip takes over: 1 action per 10 seconds per pool. This keeps a depleted account alive at a trickle rather than hard-failing, while still throttling abuse. Charges that exceed the remaining pool are rejected the same way as the IP layer (with a retry hint).Charges are refunded automatically if an accepted order fails to publish downstream (a transient internal error). Engine rejections do not refund — the matching-engine work was already consumed.
Checking your remaining budget
GET /v1/rateLimit?address=<0x...>&accountIndex=<n> returns a live snapshot of both pools:
used— units consumed since your last reseed.cap— current effective cap (starting cap plus volume replenishment).nextAvailableMs—0when you have headroom; otherwise the milliseconds until the next drip token frees up.
accountIndex (camelCase), the same as everywhere else in the API. An unrecognised parameter name is not an error — it is ignored, and the request silently resolves to index 0. Because the response echoes back the accountIndex it actually served, compare that field against the index you asked for to catch a misspelling. An out-of-range accountIndex (above 9) returns 400.
Dead man’s switch quotas
The dead man’s switch (scheduleCancel) draws from the cancel pool on every arm/refresh like other cancels, and adds two quotas of its own:
- Auto-fires — capped at 10 per UTC day per subaccount, shared across all of that subaccount’s switches (account-wide and per-market).
- Live switches — a wallet may hold at most 50 switches armed at once, across all its subaccounts and markets. Arming a new switch past the cap returns
429; refreshing an already-armed switch is exempt.
WebSocket limits
WebSocket connections are bounded per IP, in addition to the per-message weight charged against your IP bucket:
Only client→server
subscribe / unsubscribe messages (and malformed frames) count against the outbound-message rate. Well-formed post / get RPCs are exempt — they’re bounded by the per-subaccount trading pools and the in-flight post cap instead. Server-pushed channel_data updates are free. A rejected new connection still consumes a new-connection token, so a client hammering the connection cap burns its own budget faster.
Handling rate limits
- Read
Retry-Afteron a429and back off for at least that long — orretryAfterMson an order write, which is precise to the millisecond. A blind retry loop will keep losing. - Check the
reasonfield before you throttle yourself. It names which bucket rejected you; tuning the wrong layer costs throughput for nothing. - Prefer WebSocket subscriptions over REST polling for anything that changes frequently (order book, fills, account state). One subscription replaces a stream of weighted REST calls.
- Batch order operations. A batch of
Ncosts onlyfloor(N/40)IP weight (free on the IP base, like single writes) — far cheaper thanNseparate calls — though it still charges per item against the trading pool. You also save the per-request network round trips. - Poll
GET /v1/rateLimitif you run an aggressive order/cancel loop, and slow down asusedapproachescap. - Request smaller pages on list endpoints — a large page adds per-item weight after the fact.