# Rate limits

> 120 requests per key per minute on a fixed window, the call concurrency and daily call limits, what the headers tell you, and how to back off without a Retry-After.

Source: https://docs.amomic.in/api/rate-limits

---

Three independent limits apply to the API. They have nothing to do with each other, they all produce HTTP `429`, and they need completely different responses, so read `details.reason` before you decide what to do. The request limit applies to every route; the other two apply only to `POST /calls`.

| Limit | Scope | Value | `details.reason` |
|---|---|---|---|
| Requests | Per API key, per minute | 120 | `rate_limited` |
| Concurrent outbound calls | Per workspace | 10 | `call_concurrency` |
| Outbound calls per UTC day | Per workspace | 100 | `daily_call_budget` |
| Reserved outbound minutes per UTC day | Per workspace | 600 | `daily_call_budget` |

## 120 requests per key per minute

Every API key gets 120 requests a minute. That is the limit for all keys; there is no self-serve way to raise it and no per-request parameter that changes it.

The budget belongs to the **key**, not to the workspace, not to your IP, not to an endpoint. Two consequences follow, and they pull in opposite directions:

- **Separate keys have separate budgets.** Your order service and your nightly conversation exporter each get their own 120 a minute. A runaway batch job on one key cannot starve the other. This is the strongest practical argument for [one key per system](/api/authentication).
- **A shared key shares one budget.** If ten replicas of the same service all hold the same key, those ten processes are collectively capped at 120 a minute — not 120 each. Autoscaling a service that was comfortable at two replicas can start throwing 429s at ten without any change in per-instance behaviour.

### What counts

The limit is consumed **after** your key is verified and your workspace's standing is checked, and **before** the scope check. In practice:

- A request rejected for a bad, missing, revoked, or wrong-environment key, or for a suspended workspace, never reaches the limiter and never costs you a slot.
- A request that authenticates and then fails — a `403` for a missing scope, a `400` for a bad phone number, a `404` for an unknown id — **does** consume a slot. A tight retry loop against a request that will always 400 will exhaust your minute.
- `GET /ping` and `GET /channels` count like anything else. Do not poll them.
- `GET /api/v1/openapi.json` takes no key and is not counted.

## The window is fixed, not sliding

The counter is keyed on the wall-clock minute and reset when that minute rolls over. It is not a rolling 60-second window and not a token bucket.

This has a consequence worth designing around: **you can legitimately issue 240 requests in a 60-second span.** Fire 120 at 10:00:59 and 120 more at 10:01:01 and every one succeeds — they fall in different windows. Conversely, if you spend all 120 in the first two seconds of a minute, you are blocked for the remaining 58 seconds even though your average rate is well under the limit.

So the naive approach — hammer until you get a 429, then wait — is the worst-performing one. It clusters your traffic at the start of each window, wastes most of the minute in backoff, and produces a sawtooth throughput graph. Pacing yourself at roughly two requests a second gets more work through the same limit with no 429s at all.

The counter is maintained centrally, so the limit behaves identically whether your traffic lands on one of our instances or spreads across many.

## The headers

Successful responses carry two headers:

| Header | Example | Meaning |
|---|---|---|
| `X-RateLimit-Limit` | `120` | Requests allowed per minute for this key. |
| `X-RateLimit-Remaining` | `97` | Requests left in the current minute. |

::: warning These headers appear on successful responses only
They are attached on the non-exception path, so **no error response carries them — including the 429 itself.** You cannot read your remaining budget off a rejection, and you cannot watch the headers to detect that you are about to be throttled unless your requests are succeeding.

Practically: cache `X-RateLimit-Remaining` from the last successful response and use it as your pacing signal. When it drops below a threshold you have chosen, slow down proactively rather than waiting for the rejection.
:::

There is **no `X-RateLimit-Reset` header** and **no `Retry-After` header** anywhere on this API. Nothing tells you when the window turns over. You compute it, and it is easy to compute because the window is fixed to the clock: the reset is the **next minute boundary**. Sleep `60 - now.seconds` and a fraction, and you are in a fresh window.

## The 429 body

```json title="429 Too Many Requests"
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 120 requests/minute exceeded for this key.",
    "details": {
      "reason": "rate_limited",
      "limit": 120,
      "window_seconds": 60
    },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

`details.limit` and `details.window_seconds` restate the limit machine-readably, so your backoff logic does not have to hard-code 120 and 60.

A `429` with `reason: "rate_limited"` also proves your key is valid, unrevoked, and in the right environment — the limiter runs after all of those checks. It says nothing about scope, which is checked afterwards. Getting under the limit may simply reveal a `403` underneath.

## The limiter fails open

If the counter store is unreachable, the request is **served** rather than rejected. We would rather let a burst through than take your integration down because our bookkeeping had a bad minute.

Two things follow:

- **Do not use our rate limit as your safety brake.** If a bug in your code puts a message loop into overdrive, this limiter is not guaranteed to stop it. Put the guard in your own code — a queue, a semaphore, a per-customer send cap.
- **Absence of 429s is not proof you are under the limit.** During a fail-open period the counter is not being enforced, and `X-RateLimit-Remaining` will not reflect reality. Instrument your own request rate.

## Staying under it

::: steps

::: step Pace, do not burst
Two requests per second uses the full budget with zero rejections. A client-side limiter — a semaphore, a queue with a fixed drain rate, a token bucket refilling at 2/s — is a dozen lines and removes the problem entirely.
:::

::: step Give each system its own key
Separate budgets mean one system's bad afternoon is not another system's outage. Log the key name next to every `429` in your own logs: the console's request log doesn't record requests refused by the rate limiter.
:::

::: step Prefer webhooks to polling
Most 429 storms are polling loops. Do not poll `GET /calls/{id}` in a tight loop waiting for a call to end — subscribe to `call.ended`. Do not walk `GET /conversations` every thirty seconds — subscribe to `conversation.started` and `message.received`. [Webhooks](/webhooks/overview) turn a per-minute budget problem into a push.
:::

::: step Do not treat bulk sending as a loop over /messages
There is no batch endpoint, so a broadcast is your own loop. Pace it, checkpoint it so a crash does not restart from zero, and send each message with an [`Idempotency-Key`](/api/idempotency) derived from the recipient's record id — otherwise a restart re-messages everyone who already received it.
:::

::: step Watch the boundary, not the average
Because the window is fixed, an hourly job that fires 200 requests in one burst will always 429, however low its hourly average is. Spread the burst across the minutes it needs.
:::

:::

## Backing off correctly

The only correct wait for `reason: "rate_limited"` is "until the next minute boundary". Exponential backoff from an arbitrary base either waits too long — burning throughput you were entitled to — or too short, producing another 429 immediately.

::: codegroup

```javascript Node.js
// Waits until the start of the next minute, when the fixed window resets.
function msUntilNextWindow() {
  const now = new Date();
  return (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 250;
}

async function sendWithBackoff(payload, idempotencyKey, attempt = 1) {
  const response = await fetch("https://services.amomic.in/api/v1/messages", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(payload),
  });

  if (response.ok) {
    // Only successful responses carry these. Use them to pace the next call.
    const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
    if (Number.isFinite(remaining) && remaining < 10) {
      await new Promise((r) => setTimeout(r, msUntilNextWindow()));
    }
    return response.json();
  }

  const { error } = await response.json();

  if (error.code === "rate_limited" && error.details?.reason === "rate_limited" && attempt < 5) {
    await new Promise((r) => setTimeout(r, msUntilNextWindow()));
    return sendWithBackoff(payload, idempotencyKey, attempt + 1);
  }

  if (error.code === "rate_limited" && error.details?.reason === "call_concurrency") {
    // A different problem entirely — a line has to free up. Requeue, do not spin.
    throw Object.assign(new Error(error.message), { requeueAfterSeconds: 30 });
  }

  throw new Error(`${error.code}: ${error.message} (request_id ${error.request_id})`);
}
```

```python Python
import os, time
import requests

BASE = "https://services.amomic.in/api/v1"


def seconds_until_next_window() -> float:
    """The window is fixed to the wall clock, so the reset is the next minute."""
    now = time.time()
    return (60 - (now % 60)) + 0.25


def send_with_backoff(payload: dict, idempotency_key: str):
    for attempt in range(1, 6):
        response = requests.post(
            f"{BASE}/messages",
            headers={
                "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
                "Idempotency-Key": idempotency_key,
            },
            json=payload,
            timeout=30,
        )

        if response.ok:
            # Present on successful responses only. Pace off it.
            remaining = response.headers.get("X-RateLimit-Remaining")
            if remaining is not None and int(remaining) < 10:
                time.sleep(seconds_until_next_window())
            return response.json()

        error = response.json()["error"]
        reason = (error.get("details") or {}).get("reason")

        if error["code"] == "rate_limited" and reason == "rate_limited":
            time.sleep(seconds_until_next_window())
            continue

        raise RuntimeError(
            f"{error['code']}: {error['message']} (request_id {error.get('request_id')})"
        )

    raise RuntimeError("Still rate limited after 5 windows — check your send rate.")
```

:::

Both examples pass an `Idempotency-Key`. That is not optional decoration on a retry loop: without it, a request that timed out after the server accepted it will be executed a second time when you retry. See [Idempotency](/api/idempotency).

## The other 429: concurrent calls

A workspace may have **10 calls in progress at once**. Once that many are live, the next `POST /api/v1/calls` is rejected:

```json title="429 Too Many Requests"
{
  "error": {
    "code": "rate_limited",
    "message": "This workspace already has 10 calls in progress (limit 10). Try again shortly.",
    "details": {
      "reason": "call_concurrency",
      "limit": 10
    },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

This is a completely different constraint from the request limit and needs a completely different response.

- It is **per workspace**, not per key. Splitting your dialler across two keys does not buy you twenty lines.
- It counts **live calls**, not requests. You do not clear it by waiting for a minute boundary — you clear it when a call ends, which could be twenty seconds or four minutes away.
- Backing off to the next minute is the wrong move. **Requeue the call** with a delay of tens of seconds and let your queue's own concurrency limiter hold the line at ten.

The right structure for an outbound campaign is a worker pool sized at or below ten, driven by a queue, where `call.ended` webhooks release capacity. That way you never see this error at all. [Outbound calls](/channels/outbound-calls) covers the rest of the dialling model, and [Trigger a call from your CRM](/recipes/crm-call) shows the queue pattern end to end.

## The daily call limit

Each workspace can place up to **100 outbound calls** a day through `POST /calls`, and reserve up to **600 minutes** for them. The day is the UTC calendar day. Each call reserves its agent's maximum call length, rounded up to whole minutes, at the moment it is dialled, so a long maximum call length reaches the minute limit before the call count.

When either limit is reached, the next call is refused:

```json title="429 Too Many Requests"
{
  "error": {
    "code": "rate_limited",
    "message": "This workspace has reached its daily outbound calling safety limit.",
    "details": {
      "reason": "daily_call_budget",
      "calls": 100,
      "reserved_minutes": 412,
      "call_limit": 100,
      "minute_limit": 600
    },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

`calls` and `reserved_minutes` are what today has used; `call_limit` and `minute_limit` are the ceilings. Retrying today won't help. Hold the remaining calls and send them after 00:00 UTC (05:30 IST).

::: note One key, one budget, two exceptions
The request limit is per key. Call concurrency and the daily call limit are per workspace. If you remember one thing: `rate_limited` means wait for the clock; `call_concurrency` means wait for a phone to hang up; `daily_call_budget` means wait for tomorrow.
:::

## Next

::: cards

::: card Idempotency
[Retry safely](/api/idempotency) — required reading before you write any backoff loop.
:::

::: card Errors
[The full error contract](/api/errors), including which codes are worth retrying.
:::

::: card Webhooks
[Stop polling](/webhooks/overview). Most rate-limit problems are a polling loop wearing a costume.
:::

::: card Limits and plans
[Everything else that is capped](/operate/limits) across the platform.
:::

:::
