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 bucketPer organization (plus per-agent and platform)
Enforced byRedis sliding-window counter in front of every routeThe dial gate, on POST /v2/calls only
Configured byFixed constants in the orchestration servicePer-org config, editable without a deploy
Response when exceeded429, error 1300429, error 1300
Retry-After1–60 seconds (time left in the window)Always 5
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 600/min request ceiling, but if your organization runs 20 simultaneous lines, call number 21 is refused by the concurrency gate while your request quota is still 80% unused.

Rate limits

Every /v2 route is metered. The limiter is a two-bucket weighted sliding window in Redis, keyed per organization — not per API key. Minting more keys does not multiply your quota; all keys for an org draw on the same counter.

Buckets

Routes are split across four buckets. Each bucket has its own independent counter, so exhausting calls.create does not stop you reading executions.

BucketLimitRoutes
calls.create600 requests / minutePOST /v2/calls
POST /v2/calls/{execution_id}/stop
POST /v2/batches
POST /v2/batches/upload
POST /v2/batches/{batch_id}/schedule
executions.list600 requests / minuteGET /v2/calls
GET /v2/calls/{execution_id}
GET /v2/calls/{execution_id}/transcript
GET /v2/calls/{execution_id}/recording
GET /v2/agents/{agent_id}/executions
GET /v2/batches/{batch_id}/executions
GET /v2/usage
knowledge.upload20 requests / minutePOST /v2/knowledge-bases
POST /v2/knowledge-bases/{collection}/documents
default1000 requests / minuteEverything not listed above

What you can rely on, and what the ceiling is

The figures above are ceilings. What you can plan to sustain on the dial and read buckets is 500 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 — 300 requests in the first ten seconds, then quiet — briefly pushes the estimate over a ceiling set exactly at 500 and takes 429s while still inside its budget. Setting the ceiling at 600 lets a real client keep the 500 promise without shaping its traffic to a metronome.

Budget against 500. Treat anything between 500 and 600 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 on some buckets; 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 default, spelled out, because "everything else" is not something you can budget against:

  • all /v2/agents CRUD and clone, and /v2/agents/{agent_id}/versions
  • GET /v2/batches, GET /v2/batches/{batch_id}, and batch stop / pause / resume
  • every /v2/webhooks route, including POST /v2/webhooks/test, the delivery ledger reads, and PATCH /v2/agents/{agent_id}/event-subscriptions
  • 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/scopes, /v2/audit-logs, and all /v2/api-keys routes

The window length is 60 seconds for every bucket.

POST /v2/calls/{execution_id}/stop is metered in the calls.create bucket, not default. A stop-storm competes with your dialling for the same 600 requests per minute.

And knowledge.upload is 20 a minute, not 1000: one request there is a synchronous parse-and-embed that can hold a worker for minutes, so it is deliberately the tightest bucket 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 buckets are kept, 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 calls.create bucket (limit 600):

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

Fifteen seconds later the same 400-request history is weighted at 0.5, so the same 150 in-minute requests estimate at 350 and you have 150 headroom again. The window slides; it does not snap.

Response headers

Every /v2 response — success and failure — carries the trio:

HeaderMeaning
X-RateLimit-LimitThe bucket's ceiling for this route
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: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 43

When you exceed it

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

Status 429, with headers:

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

The limiter fails open

If Redis is unreachable, the limiter allows the request. A cache outage will never take your integration down, and you will never see a 429 caused by our infrastructure rather than your traffic. In that degraded state the headers report the full limit as remaining.

The consequence for you: the headers are advisory, not a ledger. 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. Today the three bucket limits above are the same constants for every organization — the tier is reported for your bookkeeping and does not change the numbers. If your ceiling is ever raised, rate_limits on GET /v2/user/me is the field that will show it. Read it at runtime rather than hard-coding 600.

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 reaches a terminal state and its webhook lands. A 3-minute call holds its slot for 3 minutes, whether you placed it in one request or one thousand.

The slabs

A dial must acquire a slot at four levels. It is all-or-nothing: if any level is full, the levels already taken are rolled back.

LevelWhat it capsWhere it is configuredDefault
PlatformEvery live call across all tenantsplatform_configs document _id: "concurrency", field global_limit (live-editable, picked up within 60s). Falls back to the BOLNA_GLOBAL_CONCURRENCY_LIMIT environment variable1000
OrganizationYour account's live callsorganization_configs.concurrency_limits.total_system for your org. Falls back to the BOLNA_ORG_CONCURRENCY_LIMIT environment variable20
AgentLive calls for one agentorganization_configs.concurrency_limits.per_agentInherits the org cap
CampaignLive calls for one campaignmax_concurrent_calls on the campaign (POST/PATCH /v2/campaigns, or the campaign settings dialog)No campaign ceiling

Resolved limits are cached for 60 seconds, so a capacity change takes effect within a minute on every worker without a restart or redeploy.

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 — call:

GET /api/v1/campaigns/{campaign_id}/capacity
{
  "running_now": 7,
  "effective_limit": 10,
  "limited_by": "a fair share of the organisation pool across 2 active campaigns",
  "campaign_limit": 50,
  "org_limit": 20,
  "available_now": 3,
  "fair_share": { "active_campaigns": 2, "guaranteed_floor": 10, "reserved_for_other_campaigns": 10 }
}

Note the shape of that example: a campaign_limit of 50 on an org_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 agent level only gates when it is set below the org limit

The per-agent counter is always tracked — it drives per-agent gauges — but it only refuses a dial when per_agent is explicitly lower than the org cap. Set it to something below total_system and it becomes a deliberate throttle on one agent; leave it at or above the org cap and it can never be the reason a call is refused.

This is why your effective ceiling is:

effective_max = min(org_limit, agent_limit)

which is exactly the concurrency.max field returned by GET /v2/user/me.

Legacy accounts. Organizations configured before August 2026 were seeded with the literal pair per_agent = 5 / total_system = 100. Nobody chose "5 lines per agent", so that exact pair is read as not customised and the agent inherits the org cap. Any other per_agent value is honoured as a real cap. The LEGACY_AGENT_LIMIT_INHERITS environment variable turns this behaviour off.

Enforcement can be off

The features.concurrency_management_enabled flag on your organization config (default true) decides whether the caps block anything. When it is false the counters still increment — so metrics and the current gauge stay accurate — but the caps are raised internally and nothing is ever refused.

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 up to 5 seconds for a line to free up. That window absorbs a micro-burst; anything longer is worse for you than an honest error. If nothing frees, you get:

{
  "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. The dispatcher waits BATCH_DISPATCH_SLOT_WAIT_SECONDS (default 15 seconds) for a slot, then hands the contact back to the pending queue to be re-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.

concurrency_limit on the batch create body is accepted and stored, but nothing reads it. The real gate is your organization's configured limits. There is no per-batch concurrency knob.

If Redis is unavailable

Unlike the rate limiter, the concurrency gate does not fail open. It falls back to in-process counters, which still enforce the limits — per worker process rather than fleet-wide. A background reconciler corrects any counter drift against the true in-flight call count once Redis returns.

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 the batch API 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/batches50,000400, error 1000
Rows in one CSV upload50,000400, error 1000
CSV upload size20 MB400, error 1000
Contacts pending across all your batches250,000409, error 1201
Furthest a batch may be scheduled ahead30 days400, error 1000

The 250,000 pending-contact ceiling bounds your undialled backlog across every batch, so one partner cannot queue millions of rows and starve everyone else's dispatch. Like the rate limiter, this check fails open: if the backlog count itself times out, your batch is accepted rather than rejected over a database hiccup.

Only .csv files are accepted by POST /v2/batches/upload. At most 20 offending row numbers are named in a validation message, however many bad rows the file 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": 1000,
    "calls_per_minute": 600,
    "executions_per_minute": 600
  }
}

Field by field:

FieldMeaning
concurrency.maxYour effective ceiling — min(org_limit, agent_limit)
concurrency.currentCalls holding a slot right now. One Redis read of the exact counter the dial gate consults. If Redis is unreachable it degrades to 0 rather than failing the request
concurrency.agent_maxThe per-agent ceiling. Only gates when it is below max
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 calls.create bucket
rate_limits.executions_per_minuteThe executions.list bucket
rate_limits.default_per_minuteThe default bucket
knowledge.upload is a fixed platform limit and is not reported here or configurable per organization
wallet.balanceSpendable credit: allocated minus spent, floored at zero

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 transient upstream failure) 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 bucket left: spread what is left over the window.
  return remaining > 0 ? (resetIn * 1000) / remaining : resetIn * 1000;
}

Remember the limiter fails open, so treat this as pacing, not accounting.

Designing for the limits

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

Poll executions, do not poll harder. GET /v2/calls and the transcript and recording routes share the 600/min executions.list bucket. 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 ≈ (concurrency.max × 3600) / average_call_seconds

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.