# 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.

<Callout type="info" title="API order placement">
For API trading, use [pre-signed orders](/docs/api-traders/pre-signed-orders).
Server-side signing may slow order ingestion. Follow the
[one-time setup](/docs/api-traders/pre-signed-orders#one-time-setup) and grant
`orders:place_signed` for signed single-order and batch submissions.
</Callout>

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 [#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](#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](/docs/orders#cancel-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](/docs/orders#cancel-everything-open) 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 [#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 [#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 `429` does not count.** A request turned away with `429` was not
  processed and consumed nothing from the bucket that rejected it, so
  it's always safe to resend the same request after backing off —
  including `POST /trade/v1/orders`. A `429` means the order was never
  placed. One caveat: every trading request also spends from your
  `read` budget on the way in, and that spend happens even when the
  `place` or `cancel` bucket then turns the request away — a tight
  retry loop that keeps hitting `429`s will still drain `read`, 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](#stream-connection-attempts)
  and the applicable per-IP edge caps, including unsuccessful attempts.
  Reconnect with backoff.

### Higher tier [#higher-tier]

Every account starts on the **Authenticated** tier. The **Higher** tier
provides increased request limits for accounts that need more throughput.

<Callout type="info" title="Request higher limits">
If you need higher rate limits, email [support@agara.xyz](mailto:support@agara.xyz)
to request access to the Higher tier.
</Callout>

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 [#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 `429`s, omit them:

```http
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
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](/docs/errors) 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 [#handling-a-429]

Wait the full `Retry-After`, add a little jitter, then resend the
identical request:

```python
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 [#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
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 [#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 [#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](#per-ip-edge-caps). 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 [#if-youre-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 [#testing-changes]

Test backoff and retry changes with test funds before sending traffic to this
deployment.

## Treat headers as authoritative [#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.
