# Rate limits

Rate limits protect your workspace and the service from runaway scripts and bursts. This page lists the limits, the headers that report them and how to respond to a `429`.

## Limits

Every operation belongs to one rate-limit class, shown in the API reference (as `x-rate-limit-class`). Each class is limited per key **and** per workspace, per minute, and the two modes are counted separately: test traffic never uses up your live budget.

| Class | Operations | Per key | Per workspace |
| --- | --- | --- | --- |
| `read` | Every `GET` | 600 per minute | 1,200 per minute |
| `write` | Creates, updates, deletes and other actions | 120 per minute | 240 per minute |
| `dial` | Placing a call (`POST /v1/calls`), starting and resuming a campaign | none | 30 per minute |

The per-workspace limit is shared by all keys of the workspace in that mode, so several integrations with their own keys can still reach it together. Your workspace's current limits are in [`GET /workspace`](/api/workspace#retrieve-the-workspace) under `limits.rate_limits`; read them rather than hard-coding the numbers.

Requests that are refused with `429` do not count against the limit.

### Requests that never reach an operation

Requests that are refused before they reach an operation (an invalid or missing key, an unknown path or method, a refused workspace, a body that is not JSON or is too large) do not count against your key's or workspace's limits. They are limited per source IP address instead: **300 per minute**. Once an address has used that up, every request from it, valid or not, answers [`rate_limited`](/errors/rate_limited) (429) with `Retry-After` until the minute is over. Their responses carry `RateLimit-Limit: 300`, and the `rate_limited` error's `details.limit_class` is `read`. A misconfigured client that keeps sending a bad key can therefore block other clients that share its outbound address: fix authentication errors instead of retrying them.

## Daily call limit

In **live mode**, a workspace can place a limited number of calls through the API per UTC day, as a guard against a loop or a retry storm calling people again and again. The limit that applies is `limits.daily_call_limit` in `GET /v1/workspace` (500 by default; `null` in test mode, which has no daily limit). It resets at 00:00 UTC. The count is kept durably (a restart does not reset it) and is checked as the request is accepted; if it cannot be checked, the request is refused with [`service_unavailable`](/errors/service_unavailable) rather than let through.

| Request | Counts |
| --- | --- |
| `POST /v1/calls` | One call. A replay with the same `Idempotency-Key`, if you send one, does not count again. |
| Starting a campaign | **One per recipient**, counted before any dialling. If they do not all fit in what is left today, the start is refused whole and nothing is dialled. |
| Resuming a campaign | Nothing: its start already counted. A resume is never refused for the daily limit. |

A campaign with more recipients than the daily limit (more than 500 on the default limit) therefore cannot be started through the API in one day: split it into smaller campaigns, or start it from the console.

When a request does not fit, it answers [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429). `details` say the limit, how many calls the request needed, how many are left and when the limit resets, and `Retry-After` is the number of seconds until then:

```json
{
  "error": {
    "code": "daily_call_limit_reached",
    "message": "This workspace has reached its daily limit of calls placed through the API.",
    "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" },
    "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh",
    "retry": "after",
    "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached"
  }
}
```

Nothing was dialled. Schedule the work for after `resets_at` rather than sleeping, or (for a campaign) start a smaller one that fits in `remaining`. If your expected volume is higher, write to [support@relationsai.com](mailto:support@relationsai.com) to discuss it.

## Calling capacity

Separately from your rate limits, call starts are paced across the phone network. When no capacity is free right now, `POST /v1/calls` answers [`calling_capacity_reached`](/errors/calling_capacity_reached) (429) with `Retry-After`. The request was valid and nothing was dialled: wait and send it again. For large volumes, use a campaign, which paces calls for you.

## Headers

Every response, successful or not, includes:

| Header | Meaning |
| --- | --- |
| `RateLimit-Limit` | Requests allowed in the current one-minute window by the tightest limit that applied to this request: the key's or the workspace's limit for the operation's class, or the per-address limit (300) for a request that never reached an operation. |
| `RateLimit-Remaining` | Requests left in the window. |
| `RateLimit-Reset` | Seconds until the window resets. |

`429` and `503` responses also include `Retry-After`, in seconds.

```http
HTTP/1.1 200 OK
X-Request-Id: req_4k7m2qat5xvz3c6vqnwpbdfh
RateLimit-Limit: 600
RateLimit-Remaining: 597
RateLimit-Reset: 42
```

Use them to slow down before you hit the limit: when `RateLimit-Remaining` reaches `0`, wait `RateLimit-Reset` seconds before the next request.

## Handling 429

[`rate_limited`](/errors/rate_limited) means nothing was done. For example, a contact sync that sends too many writes in one minute:

```bash
curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "phone_number": "+12025550100",
  "name": "Asha Rao",
  "consent_granted": true
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", {
  method: "POST",
  headers: {
    Authorization: "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    phone_number: "+12025550100",
    name: "Asha Rao",
    consent_granted: true,
  }),
});
console.log(response.status, await response.json());
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/contacts/upsert",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "phone_number": "+12025550100",
        "name": "Asha Rao",
        "consent_granted": True,
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 30

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Retry after the number of seconds in the Retry-After header.",
    "details": {
      "limit_class": "write"
    },
    "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh",
    "retry": "after",
    "doc_url": "https://docs.relationsai.com/errors/rate_limited"
  }
}
```

`details.limit_class` says which class (`read`, `write` or `dial`) was exceeded. Wait 30 seconds (the `Retry-After` value) plus a little random jitter, then send **the same request** again (with the same `Idempotency-Key`, if you sent one); nothing was done the first time. Then:

1. Spread the remaining requests out: when `RateLimit-Remaining` reaches `0`, wait `RateLimit-Reset` seconds before the next one.
2. For errors with `retry: "yes"` on reads, and for requests that got no response at all, use **exponential backoff with full jitter**: wait a random time between 0 and `min(cap, base × 2^attempt)`, for example a base of 1 second, doubling, with a cap of 30 seconds. For a `500` [`internal_error`](/errors/internal_error) on a write, check whether the change was applied before retrying: a `500` is not stored under the key.
3. Cap the number of attempts (for example five), and hand very long waits, such as the daily call limit, back to your scheduler.
4. If you hit `429` regularly, use fewer and larger pages (`limit=100`), prefer webhooks to polling, and use campaigns for bulk calling.

When the API cannot check rate limits because of a temporary problem on its side, it refuses the request with [`service_unavailable`](/errors/service_unavailable) (503) and `Retry-After`, rather than letting it through. Handle it the same way.

The complete runnable example is one request helper that waits for `Retry-After`, backs off with jitter, slows down when `RateLimit-Remaining` reaches `0`, and retries a write (sent without an `Idempotency-Key`) only after a `429` or `503`, which mean nothing was done; after any other uncertain answer it stops so you can check the state first.

Complete runnable example: [cURL script](/examples/fundamentals/rate-limit-backoff.sh) · [JavaScript](/examples/fundamentals/rate-limit-backoff.mjs) · [Python](/examples/fundamentals/rate-limit-backoff.py)

## Polling budget

If you poll calls instead of using webhooks, poll no more than every few seconds per call, and stop when `settled` is `true`. With the `read` limit of 600 per minute per key, polling every 3 seconds supports about 30 calls at a time per key; beyond that, use [webhooks](/fundamentals/webhooks).
