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.
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.
- 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
403for a missing scope, a400for a bad phone number, a404for an unknown id — does consume a slot. A tight retry loop against a request that will always 400 will exhaust your minute. GET /pingandGET /channelscount like anything else. Do not poll them.GET /api/v1/openapi.jsontakes 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. |
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 #
{"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-Remainingwill not reflect reality. Instrument your own request rate.
Staying under it #
- 1
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.
- 2
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
429in your own logs: the console's request log doesn't record requests refused by the rate limiter. - 3
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 tocall.ended. Do not walkGET /conversationsevery thirty seconds — subscribe toconversation.startedandmessage.received. Webhooks turn a per-minute budget problem into a push. - 4
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-Keyderived from the recipient's record id — otherwise a restart re-messages everyone who already received it. - 5
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.
// 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})`);}
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.
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:
{"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 covers the rest of the dialling model, and Trigger a call from your CRM 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:
{"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).
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 #
Idempotency
Retry safely — required reading before you write any backoff loop.
Errors
The full error contract, including which codes are worth retrying.
Webhooks
Stop polling. Most rate-limit problems are a polling loop wearing a costume.
Limits and plans
Everything else that is capped across the platform.