Rate limits
How many requests you can make, what happens when you exceed, and how to build a client that backs off cleanly.
The API caps how many requests a single client can make. Caps exist to keep the service responsive for everyone. Most clients never come close to hitting them.
The following caps compose, and they work differently:
- Per-account caps on the trading API (
/trade/v1/...) are token buckets: you can spend up to a bucket's burst budget instantly, and tokens return at a steady refill rate. They're tied to your account — every request you authenticate is metered, regardless of which IP you call from. - Per-IP caps at the edge are sliding windows: total requests from one IP over the trailing 60 seconds, counted on every request, authenticated or not. A safety net that mainly catches misbehaving bots and unauthenticated abuse.
API order placement
For API trading, use pre-signed orders.
Server-side signing may slow order ingestion. Follow the
one-time setup and grant
orders:place_signed for signed single-order and batch submissions.
The rate limits below apply regardless of how you sign. A request budget is not a guarantee of order-ingestion speed. The retry example on this page uses server-signed placement; the same backoff rules apply to pre-signed requests.
Per-account caps
Caps apply per user account (not per token — if you mint multiple personal access tokens, they share one envelope).
Five independent buckets. For each, you can spend up to the burst instantly; tokens come back at the refill rate, capped at the burst.
| Bucket | What it covers | Authenticated tier | Higher tier |
|---|---|---|---|
| Read | Every authenticated request debits this — see request costs. | 100 burst, refills at 50/sec | 750 burst, refills at 500/sec |
| Place | Single order placement (POST /trade/v1/orders and POST /trade/v1/orders/signed). | 100 burst, refills at 10/sec | 500 burst, refills at 150/sec |
| Place-batch | Batched pre-signed orders, account batches, and batch supersedes. One token per call, whatever the batch size. | 10 burst, refills at 1/sec | 100 burst, refills at 20/sec |
| Cancel | Cancelling one order (DELETE /trade/v1/orders/{order_id}). | 200 burst, refills at 25/sec | 2,000 burst, refills at 350/sec |
| Cancel-all | Cancelling everything open (POST /trade/v1/orders/cancel-all). | 10 burst, refills at 1/sec | 10 burst, refills at 1/sec |
The batch bucket is separate so a ladder re-quote can't drain your
single-order Place budget. Because one batch call costs one token no
matter how many orders it carries (up to 32), batching is far cheaper on
your budget than the same orders sent one at a time. An account on the Higher
tier can place up to 32 × 20 = 640 orders/sec through it.
The individual Cancel budget sits above the single-order Place budget so
pulling quotes one at a time has extra headroom. Cancel-all has its own lower
bucket; prefer one
cancel-all call over looping
individual cancels because it is more efficient, not because its limit is
higher. One request schedules cancellation for the orders visible
when each wallet's sweep begins; keep reconciling the fully paginated order list
until the relevant orders are terminal.
Request costs
Most requests cost 1 token. The exceptions, exactly:
| Request | Cost |
|---|---|
GET /trade/v1/portfolio/summary | 5 Read |
POST /trade/v1/portfolio/positions/list | 5 Read |
POST /trade/v1/portfolio/open-orders/list | 5 Read |
GET /trade/v1/portfolio/trades | 5 Read |
GET /trade/v1/portfolio/activities | 5 Read |
POST /trade/v1/portfolio/activities/list | 5 Read |
Everything else costs 1. Place, place-batch, cancel, and cancel-all requests debit 1 token from their own bucket plus their Read cost — Read meters your total request rate, the others meter the specific action. A batch call debits exactly one Place-batch token regardless of how many orders it carries, even if some of those orders are rejected.
What counts against what
- Rejected orders still count. An order that fails validation or bounces on insufficient balance consumed a Place token getting there. The buckets meter requests, not successful orders.
- A
429does not count. A request turned away with429was not processed and consumed nothing from the bucket that rejected it, so it's always safe to resend the same request after backing off — includingPOST /trade/v1/orders. A429means the order was never placed. One caveat: every trading request also spends from yourreadbudget on the way in, and that spend happens even when theplaceorcancelbucket then turns the request away — a tight retry loop that keeps hitting429s will still drainread, so back off rather than hammer. - Streaming messages are free; connecting is not. Messages over an open WebSocket or SSE stream consume no request budget. Each connection attempt counts against that endpoint's stream attempt limit and the applicable per-IP edge caps, including unsuccessful attempts. Reconnect with backoff.
Higher tier
Every account starts on the Authenticated tier. The Higher tier provides increased request limits for accounts that need more throughput.
Request higher limits
If you need higher rate limits, email support@agara.xyz to request access to the Higher tier.
After your account moves to the Higher tier, the larger limits apply from your next request; no new token or sign-in is required. Treat the returned headers as authoritative instead of assuming a tier from client configuration.
Response headers
Authenticated trading responses normally report the bucket you're closest
to draining. Treat these headers as optional because some responses,
including per-IP 429s, omit them:
X-RateLimit-Bucket: read
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 98
X-RateLimit-Reset: 1| Header | Meaning |
|---|---|
X-RateLimit-Bucket | The bucket closest to empty. |
X-RateLimit-Limit | Bucket capacity (the burst budget). |
X-RateLimit-Remaining | Tokens left right now. |
X-RateLimit-Reset | Seconds until the bucket is fully refilled. |
When a request trips a bucket you get a 429 Too Many Requests:
HTTP/2 429 Too Many Requests
Retry-After: 1
X-Request-ID: 8051f907-4eeb-4ca9-bd84-b447ea65a68c
X-RateLimit-Bucket: place
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 10
Content-Type: application/problem+json
{
"type": "urn:agara:problem:rate-limited",
"title": "Too many requests",
"status": 429,
"code": "rate_limited",
"request_id": "8051f907-4eeb-4ca9-bd84-b447ea65a68c",
"recovery": {
"strategy": "retry_after",
"after_seconds": 1
}
}| Header | Meaning |
|---|---|
Retry-After | Seconds to wait before this request will fit. |
X-Request-ID | Matches request_id in the body; retain it when contacting support. |
X-RateLimit-Bucket | Which bucket tripped: read, place, place_batch, cancel, or cancel_all. |
X-RateLimit-Limit | Capacity of the bucket that tripped. |
X-RateLimit-Remaining | Tokens left in that bucket right now. |
X-RateLimit-Reset | Seconds until that bucket is back to full. |
Use the stable code for programmatic handling; the title is for display.
recovery.after_seconds matches Retry-After.
See Errors and recovery for the shared Problem Details fields and how to handle unknown recovery strategies safely.
Retry-After and X-RateLimit-Reset answer different questions: wait
Retry-After seconds and this one request fits again; wait
X-RateLimit-Reset seconds and your full burst budget is back.
Handling a 429
Wait the full Retry-After, add a little jitter, then resend the
identical request:
import os
import random
import time
for attempt in range(MAX_RETRIES):
response = session.post(f"{os.environ['AGARA_BASE_URL']}/trade/v1/orders", json=order)
if response.status_code != 429:
break
wait = int(response.headers.get("Retry-After", "1"))
time.sleep(wait + random.uniform(0, 0.5))Retrying a rate-limited order placement is safe: the 429 means the
request was not processed, so resending it cannot double-place the
order.
Per-IP edge caps
These apply before any account lookup, on every request. Each cap is a sliding window — total requests from your IP over the trailing 60 seconds:
| What you're calling | Cap |
|---|---|
Trading, including stream connections (/trade/v1/...) | 14,000 per minute — about 233 per second |
Market data, events, prices, search (/api/v1/...) | 6,000 per minute — about 100 per second |
| Everything else (web pages, docs) | 16,000 per minute — about 267 per second |
The two specific caps apply on top of the catch-all, so a burst of
/api/v1/ calls counts against both the API cap and the overall cap.
The per-IP trading cap is shared by all accounts using the same IP. A
single Authenticated-tier account reaches its account limits before
the trading edge cap. An account on the Higher tier running at full rate
through a single IP can
reach the edge cap first — if that's your setup, mention it when you
request the tier and we'll plan around it with you.
Edge counting has a brief settling delay: a sudden spike may slip
through for a few seconds before blocking starts, and may take a few
seconds to clear after you slow down. Treat the cap as an average over
the window, not an instantaneous limit, and build your client to back
off on 429 rather than ride right up to the line.
On an edge 429, wait for the full Retry-After. The response uses the
same rate_limited code, but omits the account-bucket X-RateLimit-*
headers, X-Request-ID, and the body request_id:
HTTP/2 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"type": "urn:agara:problem:rate-limited",
"title": "Too many requests",
"status": 429,
"code": "rate_limited",
"recovery": {
"strategy": "retry_after",
"after_seconds": 60
}
}Public request and connection limits
On app.agara.xyz, ordinary HTTP requests share a burst budget per IP within each service.
Switching endpoints or HTTP methods does not give you
another service budget. Individual endpoints may also have a separate request limit. These
limits apply in addition to the per-account and per-IP edge caps above, and
operators can adjust them.
| Service | Default burst | Default requests/sec |
|---|---|---|
API (/api/v1/*) | 400 | 400 |
| Web pages and web-owned routes | 400 | 400 |
Router (/trade/v1/*) | 1,000 | 1,000 |
Responses served directly from the CDN cache do not spend this request budget. The seven trading-action endpoints listed above also have separate per-IP limits with the Higher-tier burst and refill values. Service, endpoint and account limits all apply; their budgets are not added together.
When this burst budget is exhausted, wait for Retry-After before retrying.
The response uses application/problem+json, code rate_limited, and an
X-Request-ID matching the body's request_id. It omits the account-bucket
X-RateLimit-* headers. If admission is temporarily unavailable, you receive
503 with code dependency_unavailable; follow its retry instructions.
Stream connection attempts
Each endpoint below allows 300 connection attempts per IP per 60 seconds:
| Endpoint | Transport |
|---|---|
/api/v1/prices/stream | SSE |
/api/v1/prices/pyth-pro/stream | SSE |
/trade/v1/market-stream | WebSocket |
/trade/v1/account-stream | WebSocket |
/trade/v1/portfolio/stream | SSE |
Each endpoint has its own counter, shared by all accounts using the same IP. Query parameters and an optional trailing slash do not create another allowance. Unsuccessful attempts and reconnects count too. These five endpoints do not spend the service-level burst budgets above; the per-IP edge caps and any applicable account limits still apply.
This is an attempt limit, not a cap on simultaneous open connections or messages. Reusing an open connection spends no additional attempts. Closing one does not restore attempts already counted in the trailing window, and reaching the limit does not disconnect existing streams.
On a connection-attempt 429, wait the full Retry-After: 60, add jitter, then
reconnect. The response uses application/json with code rate_limited, as in
the edge response above. Counting has a brief settling delay,
so do not depend on rejection at exactly attempt 301 or immediate recovery after
slowing down.
If you're behind a shared IP
The per-IP edge caps are per source IP. If your application calls the API from a shared egress — corporate network, NAT gateway, a serverless function pool with a small IP range — every caller behind that network shares the per-IP cap. The per-account caps above are unaffected by shared IPs.
In practice the per-IP caps almost never bite because they're well above normal usage. If you're operating from an egress that genuinely needs more headroom, get in touch.
Testing changes
Test backoff and retry changes with test funds before sending traffic to this deployment.
Treat headers as authoritative
Do not hardcode the table as a permanent contract. Use the returned limit, remaining, reset, and retry headers so your client follows the limits active for its account.