Errors & Statuses
The /v2 error envelope, its integer code table, every HTTP status this API returns, and the full execution and batch status enums with polling examples.
Everything served under https://api.graine.ai/v2 answers failures with one
envelope and one integer code table. This page is the reference for both, plus
the status enums you will poll against.
The error envelope
Every 4xx and 5xx under /v2 has exactly this body. No nesting, no detail
key, no validation array:
erroris a stable integer from the table below. Branch on it.messageis a single human-readable sentence ending in a period. It never contains a stack trace, another system's response body, a database query or a phone number. Do not branch on it — the wording can change.- A successful response never carries an
errorkey.
The HTTP status follows from the integer code: each code has one status, with
one exception — error 1000 also arrives as 405, 411, 413 or 415, the
statuses HTTP defines for those cases (see the table below). Switch on the code
and you never need the exception.
Only /v2 uses this shape
The older /api/v1 surface returns {"detail": ...} and is untouched by
this contract, so integrations already built on it keep the shapes they
parse. Build new integrations on /v2.
HTTP status codes
| Status | Meaning | When you see it on this API |
|---|---|---|
200 OK | Success with a body. | Every GET, PUT and PATCH, and the POST actions on something that already exists: campaign pause / resume / revive / cancel, knowledge-base query, and POST /v2/transliterate. Also the deletes that answer with a result body — DELETE /v2/agents/{agent_id}, DELETE /v2/api-keys/{key_id} and both knowledge-base deletes. Also POST /v2/calls/{execution_id}/stop when the call was still scheduled and was cancelled before it ever dialled. |
201 Created | A resource now exists. | POST /v2/agents, POST /v2/agents/{agent_id}/clone, POST /v2/agents/{agent_id}/versions, POST /v2/calls (dialling now), POST /v2/campaigns, POST /v2/inbound-agents, POST /v2/webhooks, POST /v2/knowledge-bases, POST /v2/knowledge-bases/{collection}/documents, POST /v2/api-keys. |
202 Accepted | Work was accepted but is not finished. | POST /v2/calls when the call was booked rather than dialled (status: "scheduled" — you supplied scheduled_at, the agent's calling window was shut, or every line was busy) or the dial was not confirmed in time (status: "queued", dispatch: "pending_confirmation"). Also POST /v2/calls/{execution_id}/stop for a live call, POST /v2/calls/{execution_id}/transfer, and POST /v2/webhooks/test. |
204 No Content | Success, empty body. | DELETE /v2/campaigns/{campaign_id}, DELETE /v2/webhooks/{subscription_id} and DELETE /v2/agents/{agent_id}/versions/{version_id}. There is no body to parse; the X-RateLimit-* headers are still there. |
302 Found | Redirect. | GET /v2/calls/{execution_id}/recording?redirect=true only. The response is a redirect straight to the stored audio; the audio itself is never served through this API. The X-RateLimit-* headers are copied onto it. |
400 Bad Request | Malformed or contradictory input the schema could not reject on its own. | Error 1000 or 1002. |
401 Unauthorized | Missing, unknown, or inactive API key — or a browser session token. | Error 1100 or 1101. |
402 Payment Required | The wallet cannot fund the call. | Error 1400. |
403 Forbidden | The key is valid but not for this organization, or lacks the scope. | Error 1102. |
404 Not Found | No such agent, call, campaign, webhook, knowledge base or key in your organization — or no such path. | Error 1200. |
405 Method Not Allowed | The path exists, but not with this method. | Error 1000. The Allow header names a method the path does accept. |
409 Conflict | The resource's current state does not allow the action. | Error 1201 — or 1202 / 1203 when the agent is switched off or was deleted, or 1204 when the number has had its calls for now. |
411 Length Required | An upload declared no Content-Length. | Error 1000. POST /v2/knowledge-bases and POST /v2/knowledge-bases/{collection}/documents refuse chunked uploads before reading them. |
413 Content Too Large | An upload is bigger than accepted. | Error 1000. The same two knowledge-base uploads, over the per-file or per-request size limit. |
415 Unsupported Media Type | A file is not a type a knowledge base can read. | Error 1000. Upload PDF, PNG, JPG or JPEG. |
422 Unprocessable Entity | The request body or query string failed validation; the message names the field. | Error 1001. |
429 Too Many Requests | Per-organization request rate exceeded, or every line was busy and the call could not be queued. | Error 1300. Always carries Retry-After. |
500 Internal Server Error | An unhandled exception. | Error 1500. The message is always the constant "An unexpected error occurred." — details go to server logs, never onto the wire. |
502 Bad Gateway | — | Never returned by /v2. A service the request depends on that is down or failing is reported as 503, and one that runs out of time as 504. There is no 502 in the code table. |
503 Service Unavailable | A service the request depends on is temporarily unavailable. | Error 1501. |
504 Gateway Timeout | A service the request depends on did not respond in time. | Error 1502. |
404, not 403, for another tenant's resources
Asking for an agent, call or campaign that exists but belongs to a
different organization returns 404 — "Agent not found.", "Call not found.", "Campaign not found." — never 403. That is
deliberate: a 403 would confirm the id exists and let one customer probe
another customer's id space. A 403 on this API always means something about
your own key: it is not bound to an organization, you passed an
organization_id that is not yours, or you are missing a scope.
Error code table
| Code | HTTP | Meaning |
|---|---|---|
1000 | 400 | Malformed or contradictory input the schema could not reject. Also 405, 411, 413 and 415 — see above. |
1001 | 422 | Validation rejected the body or query; the message names the field. |
1002 | 400 | A required parameter is absent. |
1100 | 401 | Missing, unknown, or inactive API key. |
1101 | 401 | A non-gat_ bearer (a browser session token) was presented. |
1102 | 403 | The key is valid but not for this organization, or lacks the scope. |
1200 | 404 | No such resource in this organization, or no such path. |
1201 | 409 | The resource's state does not allow the action. |
1202 | 409 | The agent is switched off, so it takes no new calls. |
1203 | 409 | The agent was deleted, so it takes no new calls. |
1204 | 409 | A workflow's Before a call is placed holds this agent's calls, and the number has had its calls for now. Carries Retry-After. |
1300 | 429 | Per-organization request rate exceeded, or no line free and the call could not be queued. |
1400 | 402 | The wallet cannot fund the call. |
1500 | 500 | Unhandled exception. |
1501 | 503 | A service the request depends on is temporarily unavailable. |
1502 | 504 | A service the request depends on did not respond in time. |
1000 — invalid request
400. The request parsed, but the values do not make sense together, or a
value is outside what this API accepts.
Other real 1000 messages include "The uploaded CSV file is empty.",
"The uploaded CSV file has no header row.", "The request body must be a JSON object.", "No contacts were supplied.", and
"Invalid value for 'direction': expected one of inbound, outbound."
1001 — validation failed
422. Schema validation rejected the request. The message is built from the
first validation failure and names the field the way you wrote it — the
body / query / path prefix is stripped.
1002 — missing parameter
400. A required parameter is absent.
1100 — unauthorized
401. Three distinct causes share this code, each with its own message: no
Authorization header, a credential that is not a recognised key, or a key
whose status is not active.
1101 — session token rejected
401. You sent a bearer credential that does not start with gat_ — almost
always a dashboard session token pasted into an API client. This is checked
before anything else, so the answer is immediate.
1102 — forbidden
403. Your key is real, but it is not entitled to this. Three real messages:
The organization_id query parameter is optional on most routes, and any
value other than your own key's organization is a hard 403 — never a silent
override.
That last one is the scope check, and it applies to every endpoint except
GET /v2/user/me and GET /v2/scopes. GET /v2/scopes lists each scope, the
endpoints it gates and whether your key holds it.
1200 — not found
404. Scoped to your organization in every case.
The recording route deliberately distinguishes two 404s that a client must
handle differently: "Call not found." means no such execution in your
organization, while "No recording is available for this execution." means the
call is real but produced no audio — it was never answered, recording was
disabled for it, or it has not finished yet.
1201 — conflict
409. The resource exists and you may touch it, but not in its current state.
That second one is the moment between a call being accepted for dialling and
its first status update arriving: the execution is real, it just has nothing to
hang up yet. It carries Retry-After: 2. It is a 409 and not a 404
precisely because "retry in a moment" and "no such call" demand opposite client
behaviour.
That one answers "status": "ACTIVE" on POST /v2/agents or PATCH / PUT /v2/agents/{agent_id}
where the workspace requires a passing test run before an agent goes live
(Go-live check). The message says whether the agent has no
test run, its latest run didn't pass, or a run is still going. Nothing in the request was saved: run
the agent's tests, and send it again once the latest run passes.
1202 — agent switched off
409. The agent is switched off, so it takes no new calls. Nothing was dialled,
queued or booked.
You get it from POST /v2/calls, and when you start a campaign that calls with
that agent — creating one with contacts, resume, revive, or moving a finished
campaign's end_time later — with the message "This campaign's agent is switched off, so nothing can start calling. Switch the agent on, then try again." Switch the agent on and send the same request again.
Calls already in progress finish normally. A campaign that was calling when its
agent was switched off pauses on its own: no contact is failed, no try is used
up, retries and call-backs keep their place, and calls you scheduled wait. It
picks up again within about a minute of the agent being switched on. Inbound
calls to the agent's numbers are turned away while it is off, and web and in-app
sessions with it do not start (a WebSocket session closes with code 4409).
1203 — agent deleted
409. The agent was deleted, so it takes no new calls. Nothing was dialled,
queued or booked.
The same requests as 1202 return it. A campaign that was calling with the
agent pauses and does not resume on its own: create a campaign with another
agent to keep calling. Calls already in progress finish normally.
1204 — the number has had its calls for now
409. A workflow's Before a call is placed
holds this agent's calls, and the number has already had as many calls as it allows in its window. Nothing was
dialled. Retry-After says how many seconds until the person can be called again, and the message says when.
You rarely see it: POST /v2/calls queues a held call instead — 202, status: "scheduled",
reason: "held_by_call_limit", placed at its scheduled_at — and answers 1204 only when it can't be queued. A
call sent with scheduled_at is never refused: it waits, and is placed once the person can be called again.
1300 — rate limited
429. Two very different situations share this code; tell them apart by the
message and by Retry-After.
Request rate. A per-organization sliding window, counted per group of endpoints:
| Group | Limit | Applies to |
|---|---|---|
| Everything else | 1000 / minute | Every route not listed below. |
| Placing calls | 600 / minute (500 sustained) | POST /v2/calls, POST /v2/calls/{execution_id}/stop, POST /v2/calls/{execution_id}/transfer, POST /v2/campaigns. |
| Reading calls | 600 / minute (500 sustained) | GET /v2/calls, GET /v2/calls/{execution_id} and its /transcript, /recording and /timing, GET /v2/agents/{agent_id}/executions, GET /v2/usage. |
| Knowledge-base uploads | 20 / minute | POST /v2/knowledge-bases, POST /v2/knowledge-bases/{collection}/documents. |
| Transliteration | 60 / minute | POST /v2/transliterate. |
The window is 60 seconds. The figures are ceilings; a plan can raise them.
Your own live figures are on GET /v2/user/me under rate_limits, and on
every response's X-RateLimit-Limit.
Concurrency. Every line your organization may use is busy. An immediate
POST /v2/calls is normally queued rather than refused: it answers 202
with status: "scheduled" and reason: "waiting_for_a_free_line", and dials
the moment a line frees. You see this 429 only when the call cannot be queued:
This one carries Retry-After: 5. If the live limit cannot be read the message
degrades to "Account concurrency limit reached; all lines are in use."
1400 — insufficient credits
402. The request was well-formed and authorized; the wallet cannot fund the
call.
It is returned once the account balance is zero or below. The balance can
be negative: a call that has already connected is charged in full when it
ends, whatever the balance. A request with scheduled_at is refused the same
way, with the message "This account has insufficient credits to schedule a call. Add credits and schedule it again." Calls scheduled earlier are held
until credits are added — see Call
Lifecycle.
For a sub-account that uses its parent organization's credits, it is returned when the parent's balance is zero or below, or when the sub-account has reached its monthly limit; the message says which.
Web and in-app sessions don't start either: a WebSocket session closes with
code 4402.
1500 — internal error
500. The message is a constant. Nothing about the failure reaches you.
1501 / 1502 — temporarily unavailable, and timed out
503 and 504. A service the request depends on is temporarily unavailable
(1501) or did not respond in time (1502). The message names which part of
the platform it was — useful in a support ticket, not something to branch on:
A 1502 reads the same way and ends "did not respond in time.". One 1501
carries a sentence of its own instead:
"The signing secret could not be created. Retry shortly.".
Both are retryable with backoff. A 429 (error 1300) can also come from a
part of the platform that is busy rather than from your own limits; it carries
a Retry-After when one is known — honour it the same way.
Rate limit headers
Every /v2 response after your key is authenticated — success and
failure — carries the same trio. A 401 comes before your organization is
known, so it carries none:
X-RateLimit-Reset is seconds until the window rolls, not a timestamp. A
429 adds Retry-After, also in seconds.
A 429 for your rate always means a real limit
If the rate limiter itself is briefly unavailable, requests are allowed
rather than refused. A rate-limit 429 therefore always means a real limit
was hit.
Execution status
status on an Execution object. Sixteen values — the same statuses the
dashboard's Call History shows, one-to-one.
Every Execution object also carries a boolean is_terminal, computed from the
canonical status — use it and you never have to hard-code the set.
| Value | Phase | Meaning |
|---|---|---|
queued | in-flight | Handed to the telephony network and not yet ringing. The status POST /v2/calls returns for an immediate call — including the unconfirmed 202 (dispatch: "pending_confirmation"). |
scheduled | in-flight | Booked and not yet dialled: you supplied scheduled_at, the agent's calling window was shut, or every line was busy (reason: "waiting_for_a_free_line"). A call its window moves again stays scheduled, with a new scheduled_at. |
rescheduled | in-flight | Kept for compatibility. No call placed today is stored with it. |
initiated | in-flight | Handed to the telephony provider. |
ringing | in-flight | The far end is ringing. |
in-progress | in-flight | Connected; the conversation is happening. Note the hyphen. |
completed | terminal | The call connected and ran to its end. The only status that counts as a reached contact. |
failed | terminal, failure | The call did not complete. error.message says why when a reason was recorded; one that begins agent_unavailable means the person answered and the voice agent never joined. |
busy | terminal, failure | The far end was busy. |
no-answer | terminal, failure | Rang out unanswered. Also the status usually recorded when a voicemail box picks up — see below. |
canceled | terminal | Cancelled. One l — see the spelling note. |
stopped | terminal | Kept for compatibility; no call placed today is stored with it. It is the acknowledgement status of POST /v2/calls/{execution_id}/stop — the call itself settles in the status recorded when it hangs up (a stopped live call is recorded canceled). |
balance-low | terminal | A billing event: the wallet could not carry the call. Terminal, but not a failure and not a retry trigger. |
error | terminal, failure | An error ended the call. |
call-disconnected | terminal, failure | The leg dropped. |
do_not_call | terminal | The number is on the workspace's do-not-call list: nothing was dialled and nothing was charged. Not a failure; retries and follow-ups never act on it. Note the underscores. |
The three sets
Terminal — ten values. Nothing further will change the status:
In-flight — the exact complement of terminal, six values. Keep polling:
Failure — a strict subset of terminal, five values. These are the ones campaign retries act on:
Note what is not in the failure set: canceled, stopped, balance-low and
do_not_call are terminal but are deliberately excluded, because a user-requested
hangup, an empty wallet and a number on the do-not-call list are not conditions a
redial would fix.
Terminal means the call is over, not that every field has landed
is_terminal: true tells you the status will not change again. It does not
guarantee that cost, recording and transcript are already populated on that
same read.
POST /v2/calls/{execution_id}/stopanswers202withstatus: "stopped"as an acknowledgement. The provider's hangup is best-effort and the stored status flips to a terminal value whether or not the audio actually stopped. Treat that202as "requested" and confirm withGET /v2/calls/{execution_id}.- The recording route's
"No recording is available for this execution."covers three cases at once — never answered, recording disabled, or not finished yet. - The transcript route answers
200withstatus: "unavailable"andformat: "none"for a call that has not produced a transcript, rather than a404, exactly so a poller can tell "not ready" from "no such call".
If you need a final cost or a recording URL, poll the execution until
is_terminal is true and the field you need is non-null — do not stop at
the status alone. A canceled execution that never dialled is the one case
where those fields will never arrive: nothing was placed, nobody was rung, and
no minutes are billed.
Spelling quirks, deliberately preserved
Two spellings are load-bearing and are not normalised away: changing either would break every integration that already matches on it.
| Concept | Execution status | Batch status |
|---|---|---|
| Cancellation | canceled — one l | cancelled — two ls |
Also: in-progress is hyphenated on an execution, and in_progress is
underscored on a batch. no-answer is hyphenated on an execution;
no_answer is underscored in a batch's per-contact accounting.
To make this survivable, the ?status= filter accepts the alternate spelling
and normalises it on the way in. These aliases are accepted:
| You may send | Normalises to |
|---|---|
cancelled | canceled |
in_progress | in-progress |
no_answer | no-answer |
call_disconnected | call-disconnected |
balance_low | balance-low |
insufficient_credit | balance-low |
insufficient_credit is an older spelling of balance-low; calls recorded
under either exist, so filtering on balance-low matches both.
Output is always the canonical value. One exception, stated plainly: an unrecognised stored value is returned to you as-is rather than being coerced into something that looks valid — the API will not invent a status.
voicemail is a filter, not a status
voicemail is not an execution status. A voicemail pickup is usually stored as
no-answer plus a separate answered_by_voicemail flag — one recognised
only after the call was recorded as connected stays completed with the flag
set. ?status=voicemail filters on the flag, whatever the status, and
?status=completed,voicemail matches either condition. voice_mail and
voice-mail are accepted spellings of the same filter.
Because it is a dimension rather than a status, a voicemail pickup is counted in
voicemail and again under its status — usually no_answer — in usage
figures, so those fields do not sum to the total.
In-flight calls are visible by default
GET /v2/calls and GET /v2/agents/{agent_id}/executions apply no implicit
terminal-only filter. A scheduled, queued or ringing call shows up while it is
pending or live. Pass ?status= explicitly if you want only finished calls. A
duplicate record merged into the call it duplicated is never listed, so one
call appears once.
Polling for completion
Poll GET /v2/calls/{execution_id} and stop on is_terminal. The examples
below also spell out the terminal set so you can see exactly what is being
waited on, and they honour Retry-After on a 429.
A single fetch, for reference:
One id from scheduled to completed
A scheduled call and a dialled call share one execution_id and one Execution
shape. The same poller works for both: a call booked with scheduled_at comes
back as status: "scheduled", is_terminal: false until its time arrives.
Batch status
status on a Batch object. These are batch-lifecycle values and are a
different enum from execution status — read the spelling note above before
comparing strings across the two.
| Value | Meaning |
|---|---|
created | The batch exists with its contacts loaded. Not yet running. |
scheduled | Waiting for its scheduled start time. |
pending | Waiting — outside a scheduled start, a campaign window or working hours — with the time it will start again stored on the batch. |
in_progress | Calls are being dispatched. |
paused | Paused mid-run. |
calls_dispatched | Every call has been dispatched; awaiting their outcomes. |
completed | Finished. |
failed | Finished in failure. |
cancelled | Cancelled. Two ls. |
expired | The campaign's end time passed mid-execution. |
Pausing, resuming and cancelling
A batch is paused, resumed and cancelled through its campaign —
pause,
resume and
cancel —
which carry the action to every batch in it. A campaign that has completed,
expired, been cancelled or been archived answers 409:
Per-call status inside a batch
GET /v2/calls?batch_id={batch_id} returns Execution objects — exactly
the same shape and the same status enum as every other call, filtered to that
batch. There is no separate per-call status vocabulary on the wire; use the
execution table above.
In-flight calls are included by default here too, so a queued or ringing call in the batch is visible while it is live.
The counts object
A Batch object carries counts with exactly five integer counts:
These are outcome buckets over the batch's contacts, not a tally of execution statuses — a contact that was retried still counts once. Each contact's state maps into them as follows:
| Count | Contact states that land here |
|---|---|
pending | Not yet dispatched. |
in_flight | Dispatched, ringing, in progress, retrying, or waiting on a scheduled follow-up. |
completed | Answered at least once, on any attempt. |
failed | Everything else that has finished: failed, busy, no answer, retries exhausted, skipped — and a contact whose follow-ups are exhausted without ever answering, since a follow-up can also be booked for a contact that never picked up. |
total | Every contact in the batch. |
completed is sticky
Once a contact has been reached, it stays in completed. A later follow-up
that fails cannot move it back into failed and cannot reduce the
completed count. If you reconcile these numbers against your own records,
reconcile against counts — re-deriving them from raw per-call statuses will
disagree, because a single contact can produce several executions.
Handling errors well
- Branch on
error, not onmessage. The integer is the contract; the sentence is for humans and may be reworded. - Retry
429,503and504. HonourRetry-Afteron a429; use your own backoff for503and504. Do not retry400,401,403,404,409or422— the same request will fail identically. 409is sometimes retryable, sometimes not."The call is still being placed. Retry in a moment."ships aRetry-Afterand means retry."This call already ended"and"This campaign is completed"do not — go read the resource instead.- Treat
402as a stop signal. A dial will keep failing until the wallet is funded. Do not loop on it. - Watch
X-RateLimit-Remainingon successes, not just on429s. It is on every authenticated response, so you can slow down before you are rejected.
Checking the API is up
GET https://api.graine.ai/v2/health answers whether this service can serve
requests at all. No authentication — a monitor that needs a credential is a
credential living in a third-party dashboard.
The body also carries a checks object with one boolean per dependency it
tested.
| Response | Meaning |
|---|---|
200 with "status": "ok" | The API can serve requests. |
503 with "status": "unavailable" | It cannot right now. The checks object says which part — useful in a support ticket, not something to branch on. |
Point an uptime check at it and treat the status code alone as the signal:
200 is healthy, anything else is not. The body is for a human reading the
alert.
Use this path, not the root
/ and /ready on api.graine.ai are not health checks for this API and
can answer 404 while it is healthy. Monitor /v2/health.
It reports availability, never your account
A 200 says the platform can take a request. It says nothing about your
wallet, your concurrency headroom or your rate-limit allowance — those come
back as 402, 429 and the X-RateLimit-* headers on the calls you
actually make. Healthy and able to dial for you right now are different
questions.

