Rate limits

Per-endpoint request budgets, the command ceiling your project holds, and how to behave when you hit one.

Each endpoint has its own budget, counted over a fixed one-minute window. The command budget belongs to your project; the other five belong to the API key that makes the request, however many processes or addresses it is used from.

EndpointLimit
POST /v1/commands/{type}600 per minute, per project and environment — the default
POST /v1/handle-check30 per minute, per key
GET /v1/attachments/{id}120 per minute, per key
GET /v1/conversations/{conversationId}/messages120 per minute, per key
GET /v1/lines/available120 per minute, per key
GET /v1/response_stream10 connects per minute, per key; 4 open at once, per key
GET /v1/whoami, GET /v1/messages/{id}/receiptsno separate budget

Failed authentication is counted separately, per calling address, at 30 a minute: a bad key cannot spend a working customer's budget, and a scan cannot spend anyone's.

The command ceiling is your project's

Every key of a project spends one command budget, and live and test have one each — a test run can never spend what your live integration needs. Minting another key does not raise it, and one runaway process can spend what your other processes were counting on.

600 a minute is the platform default. A project can carry a ceiling of its own, set by Mapier, and GET /v1/whoami reports the one that binds the key you call it with, in limits.commandsPerMinute:

GET /v1/whoami — 200
{ "projectId": "…", "environment": "live", "keyId": "…", "lines": [ … ], "limits": { "commandsPerMinute": 600 } }

Read it rather than hard-coding a figure: it is your project's own ceiling when one has been set for it, and the platform default otherwise. The console's Keys screen states the same number, read-only, with where it came from.

Asking for a higher ceiling

There is no self-serve control — a ceiling an organization could raise for itself would not be a ceiling. Mapier sets it, one number for the project, which live and test then each count against separately. Email support@mapier.ai with:

  • the projectId from GET /v1/whoami;
  • the sustained rate you need and the peak you expect, in commands a minute;
  • what you are building, so the ceiling can be sized to it.

See Getting access and support.

The stream

The stream limit counts connections, not events. Holding one stream open costs one connect; a reconnect loop that fires every second spends the budget in ten seconds. One connection every six seconds is exactly the budget, which is why the reconnect loop backs off rather than sleeping a fixed second — and why it never needs to reconnect in a hurry: the frames you miss while you wait are replayed when you reconnect with Last-Event-ID.

A key may also hold only a few streams open at once. Four are always admitted; past that a new connection may be refused with 429 rate_limited and details.reason: "too_many_streams". Close a stream you no longer read rather than opening another beside it.

Handle check is capped low on purpose — on a live line checks run one at a time, so concurrency there produces read_in_flight rather than throughput.

Exceeding a limit

HTTP/1.1 429 Too Many Requests
retry-after: 12
{ "error": { "code": "rate_limited", "message": "Too many requests. Try again in a moment." } }

Retry-After is in seconds, is always at least 1, and says when the current window ends. A per-minute budget in the table above puts the wait in that header and nowhere else: the body carries no details. Two other 429 rate_limited answers can carry details.retryAfter, in seconds, and no header. The open-streams cap above always does. A transport's own rate limit, on a route that reaches a line (a command, a handle check, an attachment or a receipts read), does when the transport gave a wait, and may put its own word in details.reason; when it gave none, the answer carries no wait at all. Read the header first, fall back to the body, and wait one second when neither says:

async function withRetry(fn: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await fn();
    if (res.status !== 429 || attempt >= 5) return res;

    const body = await res
      .clone()
      .json()
      .catch(() => ({}));
    // A 429 sandbox_budget_exhausted is a daily budget, not a window: do not wait it out.
    if (body?.error?.code !== "rate_limited") return res;
    const wait = Number(res.headers.get("retry-after") ?? body?.error?.details?.retryAfter ?? 1);
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}

A 429 sandbox_budget_exhausted is a different thing: the test world's daily action budget, not a per-minute window. It clears when the UTC day rolls over or the world is reset — see Test mode.

When the platform is busy: 503 service_busy

Not a rate limit, and not about your traffic: the platform had no database connection free for your request within its own wait. The failure is ours, it clears on its own, and nothing the request asked for was done — so sending the same request again is safe.

{
  "error": {
    "code": "service_busy",
    "message": "The service is too busy to take this request right now. Try again shortly.",
    "details": { "retryAfter": … }
  }
}

details.retryAfter and the retry-after header carry the same number of seconds. Treat it the way you treat a 429: wait that long, then send the same request. Any /v1 route can answer it, the stream's open included.

The platform answers service_busy only when it did nothing. If it runs out of connections after a command has already reached the line, the answer is 500 internal_error instead, because repeating that request without your own Idempotency-Key could send the message twice — see Idempotency and retries.

Staying under the limit

  • Hold one stream open rather than polling. The stream is the intended way to learn about outcomes, and it costs one connection.
  • Batch nothing, pace everything. There is no bulk endpoint; 600 commands a minute is ten a second sustained, shared by every key and process of the project in that environment.
  • Cache handle checks. Reachability changes rarely.
  • Use conditional attachment requests. An If-None-Match hit returns 304 and is far cheaper than re-downloading.

On this page