Graine AI

Rate Limits & Concurrency

Two independent ceilings govern the Graine v2 API — how many requests you may send per minute, and how many phone calls may be live at once. This page explains both, how campaigns share your account's calling capacity without starving each other, how to read your limits, and how to back off correctly.

There are two different ceilings on the v2 API, and they are enforced by two different systems. Nearly every support ticket about "limits" is really a confusion between them.

Rate limitConcurrency limit
CountsHTTP requests to /v2/...Live phone calls holding a line
WindowRolling 60 secondsInstantaneous — a level, not a rate
ScopePer organization, per limitPer organization (plus per-agent and platform)
Enforced onEvery /v2 routePOST /v2/calls only
Set byYour plan — a plan can carry higher ceilings than the defaultsYour account — Graine changes it on request, without any change on your side
Response when exceeded429, error 1300202 — the call is queued and dials when a line frees. 429, error 1300, only if it cannot be queued
Retry-After1–60 seconds (time left in the window)Always 5 (on the 429)
Read yours fromrate_limits on GET /v2/user/meconcurrency on GET /v2/user/me

You can hit one without being anywhere near the other. Placing 400 calls in a minute is well inside the 1200/min request ceiling, but if your organization runs 20 simultaneous lines, call number 21 waits for a line — queued, with a 202 — while your request quota is still 80% unused.

Rate limits

Every /v2 route is metered over a rolling 60-second window, counted per organization — not per API key. Minting more keys does not multiply your quota; all keys for an org draw on the same counter.

Limits by group of endpoints

Routes are split across five groups. Each group has its own independent counter, so using up the limit for placing calls does not stop you reading them.

GroupLimitRoutes
Placing calls1200 requests / minutePOST /v2/calls
POST /v2/calls/{execution_id}/stop
POST /v2/calls/{execution_id}/transfer
POST /v2/campaigns
Reading calls1200 requests / minuteGET /v2/calls
GET /v2/calls/{execution_id}
GET /v2/calls/{execution_id}/transcript
GET /v2/calls/{execution_id}/recording
GET /v2/calls/{execution_id}/timing
GET /v2/agents/{agent_id}/executions
GET /v2/usage
Knowledge-base uploads20 requests / minutePOST /v2/knowledge-bases
POST /v2/knowledge-bases/{collection}/documents
Transliteration60 requests / minutePOST /v2/transliterate
Everything else2000 requests / minuteEvery route not listed above

What you can rely on, and what the ceiling is

The figures above are ceilings. What you can plan to sustain for placing and reading calls is 1000 a minute; the ceiling sits 20% above it.

The gap is deliberate. The limiter is a sliding window that weights the previous minute's traffic into its estimate, so a client that front-loads its minute — 600 requests in the first ten seconds, then quiet — briefly pushes the estimate over a ceiling set exactly at 1000 and takes 429s while still inside its budget. Setting the ceiling at 1200 lets a real client keep the 1000 promise without shaping its traffic to a metronome.

Budget against 1000. Treat anything between 1000 and 1200 as slack for jitter, not capacity.

Plans

The figures above are the floor, applied to every key. A key on a higher plan may carry a higher ceiling for some groups; the number that key is actually held to is the one GET /v2/user/me reports in rate_limits, and the one every response's X-RateLimit-Limit header carries. Read it there rather than assuming the floor.

Everything in the "everything else" group, spelled out, because "everything else" is not something you can budget against:

  • all /v2/agents CRUD and clone, and /v2/agents/{agent_id}/versions
  • every /v2/campaigns route except POST /v2/campaigns
  • every /v2/webhooks route, including POST /v2/webhooks/test, the delivery ledger reads, and PATCH /v2/agents/{agent_id}/event-subscriptions
  • POST /v2/calls/{execution_id}/events — sending an event into a live call is 2000 a minute, not the placing-calls 1200
  • every /v2/knowledge-bases route except the two uploads above
  • /v2/phone-numbers, /v2/providers and /v2/inbound-agents
  • GET /v2/user/me, GET /v2/usage/requests, GET /v2/scopes, /v2/audit-logs, and all /v2/api-keys routes

The window length is 60 seconds for every group.

POST /v2/calls/{execution_id}/stop counts against the placing calls limit, not the general one. A stop-storm competes with your dialling for the same 1200 requests per minute.

Its neighbour goes the other way: POST /v2/calls/{execution_id}/events, which signals a live call, is on the general 2000 limit — so reacting to things happening mid-call does not eat the budget you dial with.

And knowledge-base uploads are 20 a minute, not 2000: one request there reads and indexes whole documents, which can take minutes, so it is deliberately one of the tightest buckets on the API. Batch your documents into one upload rather than looping.

The sliding window

The counter is not a fixed per-minute reset — that would let you send a double burst across a minute boundary. Two adjacent 60-second windows are counted, and the previous one is weighted by how much of it still overlaps the present:

now       = wall-clock seconds
window    = floor(now / 60)
elapsed   = now - (window * 60)
weight    = (60 - elapsed) / 60
estimated = (previous_window_count * weight) + current_window_count

allowed   = estimated <= limit

Worked example, on the placing-calls limit (1200):

  • In minute N you sent 800 requests.
  • You are now 15 seconds into minute N+1, so weight = (60 - 15) / 60 = 0.75.
  • You have sent 300 requests so far this minute.
  • estimated = (800 × 0.75) + 300 = 600 + 300 = 900.
  • 900 is under 1200, so the request is allowed and X-RateLimit-Remaining reports 300.

Fifteen seconds later the same 800-request history is weighted at 0.5, so the same 300 in-minute requests estimate at 700 and you have 500 headroom. The window slides; it does not snap.

Response headers

Every /v2 response after your key is authenticated — success and failure — carries the trio. A 401 is answered before your organization is known, so it carries none:

HeaderMeaning
X-RateLimit-LimitThe ceiling for this route — shared with the other routes in its group
X-RateLimit-Remaininglimit - estimated, floored at 0
X-RateLimit-ResetSeconds until the current window rolls — a duration, not a Unix timestamp
Retry-AfterPresent only on a 429. Seconds to wait

X-RateLimit-Reset is a countdown in seconds, not an epoch. A client that treats it as a timestamp will sleep until 1970 or not at all.

Inspect them against any cheap route:

curl -i -X GET "https://api.graine.ai/v2/user/me" \
  -H "Authorization: Bearer gat_your_api_key_here"
HTTP/1.1 200 OK
content-type: application/json
X-RateLimit-Limit: 2000
X-RateLimit-Remaining: 1997
X-RateLimit-Reset: 43

When you exceed it

{
  "error": 1300,
  "message": "Rate limit exceeded: 1200 requests per minute for this organization. Retry in 12s."
}

Status 429, with headers:

X-RateLimit-Limit: 1200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
Retry-After: 12

The headers are a guide, not a ledger

You will never see a 429 caused by a problem on our side rather than your traffic. So treat the headers as advisory: do not build a client that assumes X-RateLimit-Remaining is an exact, transactional count. Use it to pace yourself; use the 429 and Retry-After as the authority.

Tiers

GET /v2/user/me returns a rate_limit_tier field carried on your API key. The group limits above are the floor every key gets; a tier can carry a higher ceiling for any group, and never a lower one. rate_limits on GET /v2/user/me and the X-RateLimit-Limit header report the number your key is actually held to. Read it at runtime rather than hard-coding 1200.

Concurrency limits

Concurrency is a count of simultaneously live calls, not a rate. One slot is held from the moment a dial is accepted until the call has ended and its final status is recorded. A 3-minute call holds its slot for 3 minutes, whether you placed it in one request or one thousand.

The four levels

A call needs a free slot at all four levels. If any one of them is full, the call does not start, and the slots it would have taken at the other levels stay free.

LevelWhat it capsWho sets it, and where you read itDefault for a new organization
PlatformEvery call in conversation across all of GraineGraine, as the platform grows. concurrency.global_max on GET /v2/user/meShared by every account, not set per account — read global_max
OrganizationYour account's calls in conversationGraine, for your account; ask to have it raised. Calls at once on the dashboard's Billing page100
AgentCalls in conversation for each of your agents — one number that applies to every agentGraine, for your account; ask to have it raised. concurrency.agent_max on GET /v2/user/me3 — how it applies is below
CampaignCalls in conversation for one campaignYou: max_concurrent_calls on the campaign (POST/PATCH /v2/campaigns, or the campaign settings dialog)No campaign ceiling

A call takes its slot when it is answered. While it rings it holds none: Graine rings more numbers than you have free slots — the fewer of your calls are answered, the more ring for each free slot. See Calls ringing beside your lines.

A change to your limits takes effect within a minute, with nothing to change on your side.

Campaigns share the account fairly

Your organization limit is a pool, not a per-campaign allowance. Several campaigns running at once draw from the same pool, and they are kept from starving one another by two mechanisms working together.

A guaranteed floor. Every campaign that is actively asking for lines is guaranteed at least organization limit ÷ active campaigns. That share is reserved for it whether or not it is currently using it, so a campaign that starts after another has already filled the pool still gets its lines as calls end — it never has to wait for the first campaign to finish.

Work conservation. A campaign may borrow beyond its floor, but only capacity that no other campaign is short of. So a single campaign running alone uses your entire limit — fairness never leaves lines idle — and the moment a second campaign has work, the first is held to what it can keep without starving it.

Nothing is ever cut off mid-call to rebalance. A campaign that is over its share simply stops starting new calls, so the pool re-balances as its current calls end — seconds to minutes, not instantly.

The same fairness applies to which contacts are selected for dialling, not just which are admitted: each campaign gets an equal turn at the queue, and a campaign that could not use its turn carries the remainder into the next one. Without this, a campaign with a large backlog of older contacts would fill every page of the queue and a newer campaign's contacts would never be reached at all.

max_concurrent_calls is a ceiling, never a reservation

Setting it can only lower what a campaign runs. It cannot raise a campaign above your organization limit, and it cannot claim capacity from another campaign. Leave it unset for no campaign ceiling — the account limit and the fair share still apply.

To see what is actually in force for a campaign — including which limit is binding right now — open the campaign in the dashboard. Its settings show, for example:

Running now: 7 of 10 — 3 more can start right now.
Currently limited by a fair share of the organisation pool across 2 active campaigns.

Note what that example means: a campaign ceiling of 50 on an organization limit of 20, shared with one other campaign, is really 10. Raising the campaign number further changes nothing — the account limit is what to raise.

The per-agent limit

The per-agent limit is one number that applies to each of your agents. A new organization starts at 3. How far it holds an agent back depends on what else your organization is dialling:

  • While none of your running campaigns has contacts waiting to be dialled — calls placed one at a time with POST /v2/calls, from a workflow, or as a test call from the dashboard — each agent runs at most its per-agent limit at once. Further calls on that agent wait for a line, exactly as they do at the organization limit.
  • While campaigns are dialling, your organization limit is split evenly across the agents whose campaigns have contacts waiting, and each agent may run up to that share, or its per-agent limit if that is larger. One campaign on one agent can use your whole organization limit; campaigns on two agents get up to half each. The organization limit always caps the total.

A per-agent limit at or above your organization limit never holds a call back; the per-agent count is still kept for reporting.

This is why GET /v2/user/me reports:

max = min(org_limit, agent_limit)

— the most one agent runs at once while no campaign is dialling. While campaigns are dialling, an agent can run more, up to its share of the organization limit.

Older accounts. Organizations set up before late August 2026 started with a per-agent limit of 5 and an organization limit of 100. A per-agent limit of exactly 5 that is below the organization limit is read as that old default, not a choice, so those agents use the organization limit. Organizations set up from late August to early September 2026 started with both limits at 100.

Enforcement can be off

Concurrency enforcement is on for every organization by default, and Graine can switch it off for yours. When it is off, calls are still counted — so metrics and the current gauge stay accurate — but nothing is ever held back.

GET /v2/user/me reports this as concurrency.enforced. Read max together with enforced: a ceiling quoted without that flag is not the truth about what will actually be refused.

What happens when you are at your limit

It depends on how the call was submitted.

A direct dial — POST /v2/calls waits a fraction of a second for a line to free up, then queues the call instead of refusing it: the answer is 202 with status: "scheduled" and reason: "waiting_for_a_free_line", under the execution_id you were given, and the call is dialled the moment a line frees — within the agent's calling hours. Do not resend it.

Only when the call cannot be queued at all do you get the refusal:

{
  "error": 1300,
  "message": "Account concurrency limit reached (20 of 20 lines in use)."
}

Status 429, with Retry-After: 5. When the live count or the limit cannot be read, the message degrades to "Account concurrency limit reached; all lines are in use."

A batch contact is never rejected this way. Graine waits up to 5 seconds for a slot, then puts the contact back in the queue to be dialled later. A saturated organization cycles its backlog instead of failing it — which is why a batch of 50,000 contacts against a 20-line account is a perfectly normal thing to submit.

There is no per-batch concurrency knob. The real gate is your organization's configured limits, and max_concurrent_calls on a campaign, which can only lower what those limits allow.

Telling the two 429s apart

Both carry HTTP 429 and error code 1300. Distinguish them by the message:

Message beginsCauseWhat to do
Rate limit exceeded: ...Too many requests in 60 secondsSleep for Retry-After seconds, then resume. Slow your request loop
Account concurrency limit reached ...Too many live callsSleep Retry-After (5s) and retry, or stop dialling until calls complete. Sending requests faster cannot help

A useful secondary signal: on a rate-limit 429, X-RateLimit-Remaining is 0. On a concurrency 429 your request quota was never the problem, so the header usually still shows headroom. Treat the message as authoritative and the header as a hint.

Retrying a concurrency 429 in a tight loop is the single most common integration mistake. It converts a capacity problem into a rate-limit problem and gets you refused twice over. Back off, or better, let a campaign queue the work for you.

Batch and upload quotas

These are hard caps on request size, enforced before anything is written. They are not rate limits and no amount of waiting changes them.

QuotaValueError when exceeded
Contacts in one POST /v2/campaigns50,000400, error 1000
Rows in one CSV upload50,000400, error 1000
CSV upload size20 MB400, error 1000
Contacts waiting to be dialled across all your campaigns250,000409, error 1201
Furthest a campaign's end_time may be365 days ahead400, error 1000

The 250,000 pending-contact ceiling bounds your undialled backlog across every campaign, so one account cannot queue millions of rows and delay everyone else's calls. Like the rate limiter, this check errs on your side: if your backlog cannot be counted at that moment, your list is accepted rather than rejected.

A contact list uploaded as a file must be a CSV. At most 20 offending rows are named in a validation message — CSV line numbers, or positions in a JSON contacts array — however many bad rows the list contains.

Reading your own limits

GET /v2/user/me is the one call that reports both ceilings and your live concurrency in a single read.

curl -s -X GET "https://api.graine.ai/v2/user/me" \
  -H "Authorization: Bearer gat_your_api_key_here"
{
  "organization_id": "org-live-a1b2c3",
  "developer": {
    "id": "dev_9f2c",
    "name": "Production backend",
    "key_id": "k_7d41e0a9",
    "key_preview": "gat_...9f31"
  },
  "plan": "growth",
  "rate_limit_tier": "growth",
  "concurrency": {
    "max": 20,
    "current": 7,
    "agent_max": 20,
    "global_max": 1000,
    "enforced": true
  },
  "wallet": {
    "currency": "USD",
    "balance": 412.75,
    "allocated_total": 500.0,
    "lifetime_allocated": 1500.0,
    "total_spent": 1087.25
  },
  "rate_limits": {
    "default_per_minute": 2000,
    "calls_per_minute": 1200,
    "executions_per_minute": 1200
  }
}

Field by field:

FieldMeaning
concurrency.maxmin(org_limit, agent_limit) — the most one agent runs at once while no campaign is dialling (the per-agent limit). When your per-agent limit is the lower of the two, as it is for a new organization, your organization limit is shown as Calls at once on the dashboard's Billing page
concurrency.currentCalls holding a slot right now, across your whole organization — the same count that decides whether a new call is placed or queued. If it cannot be read at that moment it is reported as 0 rather than failing the request
concurrency.agent_maxYour per-agent limit. It holds calls back only when it is below your organization limit
concurrency.global_maxThe platform-wide ceiling, shared by every tenant
concurrency.enforcedfalse means the caps are tracked but never block
rate_limits.calls_per_minuteThe placing-calls limit
rate_limits.executions_per_minuteThe reading-calls limit
rate_limits.default_per_minuteThe limit for everything else
—Knowledge-base uploads and transliteration are not reported here; the X-RateLimit-Limit header on those routes carries their figure
wallet.balanceSpendable credit: allocated minus spent, floored at zero

When your per-agent limit is at or above your organization limit, concurrency.max - concurrency.current is your dialling headroom this instant. Poll it before a burst rather than discovering the ceiling one 429 at a time.

A 402 with error 1400 means the wallet could not fund the call. That is not a limit you can retry past — top up the account first.

Backing off correctly

The rules, in order of importance:

  1. Honour Retry-After. It is a whole number of seconds and it is authoritative.
  2. Add jitter. Without it, every client that hit the wall together retries together.
  3. Cap the attempts. A 429 that survives five backoffs is a capacity problem, not a timing problem.
  4. Never retry 4xx other than 429. A 400, 402, 404 or 409 will fail identically forever.

Python

import random
import time
 
import requests
 
BASE_URL = "https://api.graine.ai/v2"
API_KEY = "gat_your_api_key_here"
 
MAX_ATTEMPTS = 5
BASE_DELAY_SECONDS = 1.0
MAX_DELAY_SECONDS = 60.0
 
 
def request_with_backoff(method, path, **kwargs):
    """Call the v2 API, honouring Retry-After and backing off exponentially."""
    headers = kwargs.pop("headers", {})
    headers["Authorization"] = "Bearer {key}".format(key=API_KEY)
 
    for attempt in range(MAX_ATTEMPTS):
        response = requests.request(
            method, BASE_URL + path, headers=headers, timeout=30, **kwargs
        )
 
        # Anything that is not a 429 (or a temporary 503 / 504) is final.
        if response.status_code not in (429, 503, 504):
            return response
 
        if attempt == MAX_ATTEMPTS - 1:
            return response
 
        # The server told us how long to wait — always prefer it.
        retry_after = response.headers.get("Retry-After")
        if retry_after is not None:
            try:
                delay = float(int(retry_after))
            except ValueError:
                delay = BASE_DELAY_SECONDS * (2 ** attempt)
        else:
            delay = BASE_DELAY_SECONDS * (2 ** attempt)
 
        delay = min(delay, MAX_DELAY_SECONDS)
        # Full jitter: spread a thundering herd across the whole window.
        delay = random.uniform(0, delay)
 
        body = response.json() if response.content else {}
        print(
            "attempt {n}: {status} error={code} — sleeping {delay:.1f}s".format(
                n=attempt + 1,
                status=response.status_code,
                code=body.get("error"),
                delay=delay,
            )
        )
        time.sleep(delay)
 
    return response
 
 
result = request_with_backoff(
    "POST",
    "/calls",
    json={
        "agent_id": "agent_7f2b91",
        "to_number": "+919876543210",
    },
)
print(result.status_code, result.json())

TypeScript

const BASE_URL = "https://api.graine.ai/v2";
const API_KEY = process.env.GRAINE_API_KEY!;
 
const MAX_ATTEMPTS = 5;
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 60_000;
 
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
 
export async function requestWithBackoff(
  method: string,
  path: string,
  body?: unknown,
): Promise<Response> {
  let response!: Response;
 
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    response = await fetch(`${BASE_URL}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
 
    if (![429, 503, 504].includes(response.status)) return response;
    if (attempt === MAX_ATTEMPTS - 1) return response;
 
    // Retry-After is a whole number of seconds, and it is authoritative.
    const header = response.headers.get("Retry-After");
    const parsed = header === null ? NaN : Number.parseInt(header, 10);
    const base = Number.isFinite(parsed)
      ? parsed * 1000
      : BASE_DELAY_MS * 2 ** attempt;
 
    // Full jitter, capped.
    const delay = Math.random() * Math.min(base, MAX_DELAY_MS);
    await sleep(delay);
  }
 
  return response;
}

Pacing without waiting for a 429

Since the trio is on every response, a client can slow itself down before it is refused:

function shouldThrottle(response: Response): number {
  const remaining = Number(response.headers.get("X-RateLimit-Remaining") ?? "0");
  const limit = Number(response.headers.get("X-RateLimit-Limit") ?? "0");
  // X-RateLimit-Reset is SECONDS REMAINING in the window, not a timestamp.
  const resetIn = Number(response.headers.get("X-RateLimit-Reset") ?? "0");
 
  if (limit === 0 || remaining > limit * 0.1) return 0;
 
  // Under 10% of the allowance left: spread what is left over the window.
  return remaining > 0 ? (resetIn * 1000) / remaining : resetIn * 1000;
}

Remember the headers are advisory, so treat this as pacing, not accounting.

Designing for the limits

Reaching more people does not need more requests. One POST /v2/campaigns carrying 50,000 contacts costs a single request against the placing-calls limit. Dialling those contacts one at a time through POST /v2/calls costs 50,000 requests and will exhaust that limit in the first twelve seconds. Use campaigns for volume; use POST /v2/calls for one-off, event-triggered dials.

Poll executions, do not poll harder. GET /v2/calls and the transcript, recording and timing routes share the 1200/min reading-calls limit. Polling every call individually every second is what exhausts it. Prefer a webhook, or list with filters on one request instead of fanning out.

Concurrency, not the rate limit, is what sizes your throughput. Calls per hour is roughly:

calls_per_hour ≈ (lines × 3600) / average_call_seconds

where lines is how many calls can run at once: your organization limit for your campaigns, or concurrency.max for calls placed one at a time while no campaign is dialling.

Twenty lines at an average of 90 seconds is about 800 calls an hour. If that is short of what you need, raising your request rate will not help — the organization concurrency limit is the number to change.