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 FastAPI 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, an upstream 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 is derived from the integer code, so a code and its status can never drift apart. Pick whichever you find easier to switch on.
Only /v2 uses this shape
The older /api/v1 surface returns FastAPI's {"detail": ...} and is
untouched by this contract. The /v2 error handlers are scoped to paths
beginning with /v2 precisely so existing /api/v1 consumers keep the shapes
they already parse.
HTTP status codes
| Status | Meaning | When you see it on this API |
|---|---|---|
200 OK | Success with a body. | All GET routes. Also DELETE /v2/agents/{agent_id} and DELETE /v2/api-keys/{key_id}, which return a result body rather than an empty response. 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/calls (immediate dial), POST /v2/batches, POST /v2/batches/upload, POST /v2/api-keys. |
202 Accepted | Work was accepted but is not finished. | POST /v2/calls when scheduled_at was supplied, POST /v2/calls/{execution_id}/stop for a live call, and POST /v2/batches/{batch_id}/resume. |
204 No Content | — | Never returned by /v2. Every route, deletions included, returns a JSON body. Do not write a client branch for it. |
302 Found | Redirect. | GET /v2/calls/{execution_id}/recording?redirect=true only. The response is a redirect straight to the stored audio; audio bytes are never proxied 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, execution, batch or key in your organization. | Error 1200. |
409 Conflict | The resource's current state does not allow the action. | Error 1201. |
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 call concurrency exhausted. | 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. An upstream that is down or returns a 5xx is reported as 503, and one that runs out of time as 504. There is no 502 in the code table. |
503 Service Unavailable | An upstream dependency is unreachable or returned a 5xx. | Error 1501. |
504 Gateway Timeout | An upstream dependency exceeded its timeout. | Error 1502. |
404, not 403, for another tenant's resources
Asking for an agent, execution or batch that exists but belongs to a different
organization returns 404 with "Agent 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. |
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 agent, execution, batch or key in this organization. |
1201 | 409 | The resource's state does not allow the action. |
1300 | 429 | Per-organization request rate exceeded, or concurrency exhausted. |
1400 | 402 | The wallet cannot fund the call. |
1500 | 500 | Unhandled exception. |
1501 | 503 | An upstream service is unreachable or returned a 5xx. |
1502 | 504 | An upstream service exceeded its timeout. |
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': 'outgoing'. Valid values are inbound and 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 absent from the key store, 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
locally, before any network round-trip, so it never reaches the auth provider.
1102 — forbidden
403. Your key is real, but it is not entitled to this. Three real messages:
The organization_id query parameter is accepted on most routes for symmetry
with /api/v1, but any value other than your own key's organization is a hard
403 — never a silent override.
That last one is the only scope /v2 enforces, and only on the /v2/api-keys
routes.
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 window between the telephony service accepting a call
and its first webhook landing: 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.
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, evaluated per bucket:
| Bucket | Limit | Applies to |
|---|---|---|
| default | 1000 / minute | Every route not listed below. |
| call create | 500 / minute | POST /v2/calls, POST /v2/calls/{execution_id}/stop, batch creation. |
| executions | 500 / minute | The execution list and per-execution read routes. |
The window is 60 seconds. Your own live figures are on GET /v2/user/me under
rate_limits.
Concurrency. No telephony line was free within 5 seconds of your dial request:
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. This is the only upstream status with a dedicated public code.
1500 — internal error
500. The message is a constant. Nothing about the failure reaches you.
1501 / 1502 — upstream unavailable and upstream timeout
503 and 504. The message names the dependency by a stable public label —
never by its internal service name.
The labels you may see: Agent service, Telephony service, Call storage,
Scheduling service, Translation service, Authentication service,
The API key store, The account service, and Usage reporting.
Both are retryable. An upstream 5xx becomes 1501/503; an upstream
timeout becomes 1502/504. An upstream 429 is passed through as 1300
with that upstream's own Retry-After where it supplied one.
Rate limit headers
Every /v2 response — success and failure — carries the same trio:
X-RateLimit-Reset is seconds until the window rolls, not a timestamp. A
429 adds Retry-After, also in seconds.
The limiter fails open
If the rate-limit store is unreachable, requests are allowed through rather
than rejected. A 429 therefore always means a real limit was hit, never
that infrastructure was flaky.
Execution status
status on an Execution object. Fifteen values, derived one-to-one from the
internal call-record enum so there is no parallel public taxonomy.
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 | Accepted and waiting to be dialled. The status POST /v2/calls returns for an immediate call. |
scheduled | in-flight | Booked for a future time and not yet dialled. The status POST /v2/calls returns when you supply scheduled_at. |
rescheduled | in-flight | Moved to a new future time. Not a failure. |
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. |
busy | terminal, failure | The far end was busy. |
no-answer | terminal, failure | Rang out unanswered. Also the status recorded when a voicemail box picks up — see below. |
canceled | terminal | Cancelled. One l — see the spelling note. |
stopped | terminal | Hung up on request. Terminal, but not a failure and not a retry trigger. |
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. |
The three sets
Terminal — nine 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 the retry engine acts on:
Note what is not in the failure set: canceled, stopped and balance-low
are terminal but are deliberately excluded, because a user-requested hangup and
an empty wallet 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. Both enums predate the public API, and inventing a third spelling to reconcile them would break every stored row.
| 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 a legacy stored value that predates balance-low;
both exist in production records, so filtering on balance-low matches
rows written under either spelling.
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 stored as
no-answer plus a separate answered_by_voicemail flag. ?status=voicemail
filters on that flag, 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
both voicemail and no_answer in usage figures — those fields do not sum to
the total.
In-flight calls are visible by default
GET /v2/calls and GET /v2/batches/{batch_id}/executions apply no implicit
terminal-only filter. A queued or ringing call shows up while it is live. Pass
?status= explicitly if you want only finished calls.
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 | Deferred by the dispatcher — outside a scheduled start, a campaign window or working hours — with a durable re-fire time 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. |
Which actions each state allows
A batch in completed, failed, cancelled or expired can no longer be
paused, resumed, cancelled or rescheduled. Attempting any of those returns:
Rescheduling is refused in those four states and additionally in
in_progress and calls_dispatched — moving the start time of a batch that is
already dialling would be a lie:
A successful reschedule returns the batch to created with the new start time
stored durably, so it survives a redeploy.
Per-call status inside a batch
GET /v2/batches/{batch_id}/executions returns Execution objects — exactly
the same shape and the same status enum as GET /v2/calls, 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 buckets:
These are outcome buckets over the batch's contacts, not a tally of execution statuses — a contact that was retried still counts once. The internal per-contact state maps into them as follows:
| Bucket | 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. Includes a contact whose follow-up attempts are exhausted, because a follow-up is only ever scheduled after an answered call. |
failed | Everything else that has finished: failed, busy, no answer, retries exhausted, or skipped. |
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 batch has already finished"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 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.
| Response | Meaning |
|---|---|
200 with "status": "ok" | Both stores answered. Every /v2 operation depends on them, so this one answer speaks for all of them. |
503 with "status": "unavailable" | One of them did not. The checks object names which — 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 service root
/ and /ready on api.graine.ai are routed to the telephony gateway, so
they answer 404 however healthy this API is. /v2/health is on a path the
load balancer actually sends here, which is the whole reason it exists.
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.

