Skip to content

Rate limits ​

One limiter covers /api/v1. It counts requests per minute, per API key.

The numbers ​

CallerLimitCounted by
An authenticated requestthe organization's plan limit, or 300/min when the plan sets nonethe API key's id
An unauthenticated request60/minthe client IP

The default of 300 comes from callagent.limits.api_rate_limit_per_minute and can be changed per deployment with the API_RATE_LIMIT environment variable.

The bucket is keyed on the key, not the organization. Two keys in one organization get two independent budgets, which is the cheapest way to stop a chatty background job from starving your checkout flow: give it its own key.

The 60/min anonymous bucket is effectively unreachable on /api/v1

Authentication runs before the limiter, so a request with no key is rejected with 401 before it is ever counted. The anonymous bucket exists for surfaces that are mounted without the key middleware.

Per-plan limits ​

A plan can raise or lower the ceiling. The limit is an entitlement on the plan, so it follows the organization rather than the key.

PlanRequests / minute
Starter60
Growth300
Enterprise1200
No plan300 (the platform default)

An organization with no plan is not unlimited — it falls back to the platform default. Individual organizations can be given an override without changing plan.

You can read your own ceiling and how much of it you have used from GET /billing/plans, which returns a limits object with limit, used and unlimited for every enforced entitlement. That is worth checking before a bulk import, rather than discovering the ceiling as a 402 halfway through one.

Headers ​

Every response through the limiter carries the budget:

http
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287

A 429 carries two more:

http
Retry-After: 34
X-RateLimit-Reset: 1767268800

Retry-After is seconds to wait. X-RateLimit-Reset is an absolute Unix timestamp — the two are different units, which is the usual way to get this wrong.

The 429 body ​

json
{
  "error": {
    "type": "rate_limit_error",
    "message": "Too many requests. Slow down and retry after the interval in the Retry-After header.",
    "retryable": true,
    "documentation_url": "https://docs.callagent.dev/api/rate-limits",
    "request_id": "01JQ…"
  }
}

Handling it ​

Sleep for Retry-After and try again. Do not compute your own backoff from the status code — the server knows when the window resets and you do not.

js
async function callWithRetry(url, init, attempts = 5) {
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429) return response;

    // Seconds, not milliseconds, and not a timestamp.
    const wait = Number(response.headers.get('Retry-After') ?? 1);
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
  }
  throw new Error('Rate limited after every attempt.');
}
php
<?php

function call_with_retry(callable $send, int $attempts = 5): array
{
    for ($attempt = 0; $attempt < $attempts; $attempt++) {
        [$status, $headers, $body] = $send();

        if ($status !== 429) {
            return [$status, $headers, $body];
        }

        // Seconds, not milliseconds, and not a timestamp.
        sleep((int) ($headers['retry-after'] ?? 1));
    }

    throw new RuntimeException('Rate limited after every attempt.');
}
bash
# --retry 5 handles 429 and 5xx; --retry-delay is a floor, curl honours Retry-After.
curl --retry 5 --retry-delay 1 --retry-all-errors \
  -X GET "$CALLAGENT_API_URL/calls" \
  -H "Authorization: Bearer $CALLAGENT_API_KEY"

Staying under it ​

Read X-RateLimit-Remaining and slow down before you hit zero. A client that only reacts to 429 spends part of every minute rate-limited.

Use one key per workload. The bucket is per key, so a batch job and an interactive integration on separate keys cannot starve each other.

Follow links.next instead of polling. A list endpoint returns a paginator; walking it costs one request per page, while re-fetching page one in a loop costs one per poll.

Do not poll for call state. Webhooks push call.* events; a poll loop against GET /calls/{call} is the single most common way an integration exhausts its budget.

Other limits, which are not this one ​

These share the word "limit" and nothing else:

  • Plan resource ceilings — how many assistants, queues, campaigns, knowledge bases, tools, API keys, phone numbers and operator seats you may have. Exceeding one is a 402 with error.type: "plan_limit_exceeded" and error.code naming the entitlement (max_assistants, max_api_keys, phone_numbers, …), plus limit and used.
  • Concurrency — how many calls may be in progress at once. Exceeding it refuses the call, not the request.
  • Provider rate limits — a transcription or model provider throttling us. That surfaces as error.type: "provider_error" with error.code: "rate_limited" and its own Retry-After, which is a fixed 5 seconds rather than a computed window.
  • Per-endpoint page sizes — limit on Engage endpoints and per_page on Platform and Billing endpoints, each capped at 100. Not a rate limit; just a ceiling on one response.

Documented against the code. Anything not yet implemented is labelled, never described as working.