> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.rray.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.rray.app/_mcp/server.

# Rate limits

Limits are fixed one-minute windows, chosen by the credential you present.

| Credential    | Limit                | Counted per                                  |
| ------------- | -------------------- | -------------------------------------------- |
| Workspace key | 300 requests/minute  | Workspace (all of its keys share the budget) |
| Platform key  | 3000 requests/minute | Account                                      |

Two things follow from "counted per":

* **Minting more keys does not raise a workspace's limit.** Every key on a workspace
  draws from the same 300.
* **Rotating a platform key does not reset its count.** The budget is keyed to the
  account, not to the token.

Every request costs exactly one unit, reads included. A
[batch](/docs/get-started/limits) of 100 messages is one request against this limit —
batching is the way to send more than 300 messages a minute on a workspace key.

The public error catalog (`GET /errors` and `GET /errors/{code}`) is the one exception:
it takes no credential and is not counted against either budget, so following a `type`
URL from an error never costs you a request.

## The headers

Every response, not just a `429`, reports where you stand:

```
RateLimit-Limit: 300
RateLimit-Remaining: 299
RateLimit-Reset: 1791359280
```

| Header                | Meaning                                                               |
| --------------------- | --------------------------------------------------------------------- |
| `RateLimit-Limit`     | Requests allowed in the current window                                |
| `RateLimit-Remaining` | Requests left in it                                                   |
| `RateLimit-Reset`     | Unix timestamp at which the window ends and the count returns to zero |

Because the window is fixed rather than sliding, `RateLimit-Remaining` jumps back to the
full limit at `RateLimit-Reset` — it does not refill gradually.

These are the request-rate limits only. Your plan allowance is a separate budget,
reported on send responses as `RRay-Quota-Limit`, `RRay-Quota-Remaining` and
`RRay-Quota-Reset`; see [Limits & facts](/docs/get-started/limits).

## Being limited

Over the limit returns `429 rate_limit.exceeded` with `Retry-After` in seconds:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 24
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-Reset: 1791359280
```

```json
{
  "type": "https://api.rray.app/errors/rate_limit.exceeded",
  "title": "Rate limit exceeded",
  "status": 429,
  "code": "rate_limit.exceeded",
  "category": "rate_limit",
  "request_id": "df7b848fabd945889820b257e004c894",
  "instance": "/v1/messages"
}
```

Honour `Retry-After` before retrying. It is never more than 60 seconds, since it is the
remaining time in the current window.

One other code shares the `429` status: `billing.daily_cap_reached`, which also carries
`Retry-After` — pointing at the next UTC midnight, not at the next minute. Branch on
`code`, not on the status, so you do not sleep a request-rate interval when the real wait
is hours.

## Backing off

Retry on `429` and on `5xx`. Never retry a `4xx` other than `429`: the request will not
start succeeding.

```python
import time
import httpx

RETRYABLE = {429, 500, 502, 503, 504}

def send_with_retry(client: httpx.Client, payload: dict, *, attempts: int = 5) -> httpx.Response:
    for attempt in range(attempts):
        response = client.post(
            "/v1/messages",
            json=payload,
            # Same key on every attempt: a retry replays the original response
            # instead of sending a second message.
            headers={"Idempotency-Key": f"order-{payload['order_id']}-receipt"},
        )
        if response.status_code not in RETRYABLE:
            return response
        if attempt == attempts - 1:
            return response
        # Trust the server's own figure when it gives one; otherwise back off
        # exponentially from one second.
        wait = float(response.headers.get("Retry-After", 2**attempt))
        time.sleep(wait)
    raise AssertionError("unreachable")
```

Three things make this safe rather than merely polite:

1. **Reuse the `Idempotency-Key` across attempts.** A timeout you retry without one can
   send the message twice. See [Idempotency](/docs/concepts/idempotency).
2. **Prefer `Retry-After` to your own guess.** It is the exact remaining window.
3. **Cap the attempts.** A send that keeps failing after a few tries needs a queue and an
   alert, not a longer loop.

## Staying under the limit

* **Batch.** `POST /v1/messages/batch` sends up to 100 messages for one unit of budget.
* **Do not poll every message.** Read a message when something needs its status; use
  [`GET /v1/stats`](/api) for aggregate numbers, which is one request for a whole window.
* **Watch `RateLimit-Remaining` in your own client.** Slowing down at 10% remaining is
  cheaper than handling a `429`.
* **Use a platform key for dashboards and a workspace key for sending.** The account
  budget is ten times larger and keeps a reporting job from eating the send budget.