Graine AI

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:

{
  "error": 1200,
  "message": "Agent not found."
}
  • error is a stable integer from the table below. Branch on it.
  • message is 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 error key.

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

StatusMeaningWhen you see it on this API
200 OKSuccess 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 CreatedA 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 AcceptedWork 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 ContentNever returned by /v2. Every route, deletions included, returns a JSON body. Do not write a client branch for it.
302 FoundRedirect.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 RequestMalformed or contradictory input the schema could not reject on its own.Error 1000 or 1002.
401 UnauthorizedMissing, unknown, or inactive API key — or a browser session token.Error 1100 or 1101.
402 Payment RequiredThe wallet cannot fund the call.Error 1400.
403 ForbiddenThe key is valid but not for this organization, or lacks the scope.Error 1102.
404 Not FoundNo such agent, execution, batch or key in your organization.Error 1200.
409 ConflictThe resource's current state does not allow the action.Error 1201.
422 Unprocessable EntityThe request body or query string failed validation; the message names the field.Error 1001.
429 Too Many RequestsPer-organization request rate exceeded, or call concurrency exhausted.Error 1300. Always carries Retry-After.
500 Internal Server ErrorAn 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 GatewayNever 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 UnavailableAn upstream dependency is unreachable or returned a 5xx.Error 1501.
504 Gateway TimeoutAn 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

CodeHTTPMeaning
1000400Malformed or contradictory input the schema could not reject.
1001422Validation rejected the body or query; the message names the field.
1002400A required parameter is absent.
1100401Missing, unknown, or inactive API key.
1101401A non-gat_ bearer (a browser session token) was presented.
1102403The key is valid but not for this organization, or lacks the scope.
1200404No such agent, execution, batch or key in this organization.
1201409The resource's state does not allow the action.
1300429Per-organization request rate exceeded, or concurrency exhausted.
1400402The wallet cannot fund the call.
1500500Unhandled exception.
1501503An upstream service is unreachable or returned a 5xx.
1502504An 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.

{
  "error": 1000,
  "message": "Unknown status 'answered'. Valid values are: balance-low, busy, call-disconnected, canceled, completed, error, failed, in-progress, initiated, no-answer, queued, rescheduled, ringing, scheduled, stopped, voice-mail, voice_mail, voicemail."
}

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.

{
  "error": 1001,
  "message": "Invalid value for 'page_size': ensure this value is less than or equal to 100."
}

1002 — missing parameter

400. A required parameter is absent.

{
  "error": 1002,
  "message": "Missing required parameter 'agent_id'."
}

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.

{
  "error": 1100,
  "message": "Missing bearer token. Send Authorization: Bearer gat_..."
}
{
  "error": 1100,
  "message": "Invalid API key."
}
{
  "error": 1100,
  "message": "API key is inactive."
}

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.

{
  "error": 1101,
  "message": "A browser session token is not a public API key. Authenticate with Authorization: Bearer gat_..."
}

1102 — forbidden

403. Your key is real, but it is not entitled to this. Three real messages:

{
  "error": 1102,
  "message": "This API key is not authorized for that organization."
}

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.

{
  "error": 1102,
  "message": "This API key is not bound to an organization."
}
{
  "error": 1102,
  "message": "This API key is not authorized to manage API keys. Ask an account admin to enable the 'keys:write' scope."
}

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.

{
  "error": 1200,
  "message": "Call not found."
}
{
  "error": 1200,
  "message": "Agent not found."
}
{
  "error": 1200,
  "message": "Batch not found."
}

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.

{
  "error": 1200,
  "message": "No recording is available for this execution."
}

1201 — conflict

409. The resource exists and you may touch it, but not in its current state.

{
  "error": 1201,
  "message": "This call already ended (status=completed)."
}
{
  "error": 1201,
  "message": "The call is still being placed. Retry in a moment."
}

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.

{
  "error": 1201,
  "message": "This batch has already finished (status=completed) and cannot be paused."
}
{
  "error": 1201,
  "message": "You cannot revoke the key you are authenticating with."
}

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:

{
  "error": 1300,
  "message": "Rate limit exceeded: 500 requests per minute for this organization. Retry in 17s."
}
BucketLimitApplies to
default1000 / minuteEvery route not listed below.
call create500 / minutePOST /v2/calls, POST /v2/calls/{execution_id}/stop, batch creation.
executions500 / minuteThe 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:

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

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.

{
  "error": 1400,
  "message": "Insufficient credits to place this call."
}

1500 — internal error

500. The message is a constant. Nothing about the failure reaches you.

{
  "error": 1500,
  "message": "An unexpected error occurred."
}

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.

{
  "error": 1501,
  "message": "Telephony service is temporarily unavailable."
}
{
  "error": 1502,
  "message": "Agent service did not respond in time."
}

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-Limit: 500
X-RateLimit-Remaining: 483
X-RateLimit-Reset: 41

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.

ValuePhaseMeaning
queuedin-flightAccepted and waiting to be dialled. The status POST /v2/calls returns for an immediate call.
scheduledin-flightBooked for a future time and not yet dialled. The status POST /v2/calls returns when you supply scheduled_at.
rescheduledin-flightMoved to a new future time. Not a failure.
initiatedin-flightHanded to the telephony provider.
ringingin-flightThe far end is ringing.
in-progressin-flightConnected; the conversation is happening. Note the hyphen.
completedterminalThe call connected and ran to its end. The only status that counts as a reached contact.
failedterminal, failureThe call did not complete.
busyterminal, failureThe far end was busy.
no-answerterminal, failureRang out unanswered. Also the status recorded when a voicemail box picks up — see below.
canceledterminalCancelled. One l — see the spelling note.
stoppedterminalHung up on request. Terminal, but not a failure and not a retry trigger.
balance-lowterminalA billing event: the wallet could not carry the call. Terminal, but not a failure and not a retry trigger.
errorterminal, failureAn error ended the call.
call-disconnectedterminal, failureThe leg dropped.

The three sets

Terminal — nine values. Nothing further will change the status:

completed, failed, busy, no-answer, canceled, stopped, error,
call-disconnected, balance-low

In-flight — the exact complement of terminal, six values. Keep polling:

queued, scheduled, rescheduled, initiated, ringing, in-progress

Failure — a strict subset of terminal, five values. These are the ones the retry engine acts on:

failed, busy, no-answer, error, call-disconnected

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}/stop answers 202 with status: "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 that 202 as "requested" and confirm with GET /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 200 with status: "unavailable" and format: "none" for a call that has not produced a transcript, rather than a 404, 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.

ConceptExecution statusBatch status
Cancellationcanceled — one lcancelled — 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 sendNormalises to
cancelledcanceled
in_progressin-progress
no_answerno-answer
call_disconnectedcall-disconnected
balance_lowbalance-low
insufficient_creditbalance-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:

curl https://api.graine.ai/v2/calls/exec_01HZY3Q4K7M8N9P0R1S2T3U4V5 \
  -H "Authorization: Bearer gat_your_api_key_here"
import time
import requests
 
BASE = "https://api.graine.ai/v2"
HEADERS = {"Authorization": "Bearer gat_your_api_key_here"}
 
TERMINAL = {
    "completed", "failed", "busy", "no-answer", "canceled",
    "stopped", "error", "call-disconnected", "balance-low",
}
FAILURE = {"failed", "busy", "no-answer", "error", "call-disconnected"}
 
# Accepted alternate spellings, normalised the way the API normalises them.
ALIASES = {
    "cancelled": "canceled",
    "in_progress": "in-progress",
    "no_answer": "no-answer",
    "call_disconnected": "call-disconnected",
    "balance_low": "balance-low",
    "insufficient_credit": "balance-low",
}
 
 
def normalise(status):
    if not status:
        return None
    lowered = status.strip().lower()
    return ALIASES.get(lowered, lowered)
 
 
def wait_for_call(execution_id, timeout=900, interval=5):
    """Poll until the execution is terminal. Returns the final Execution object."""
    deadline = time.time() + timeout
 
    while time.time() < deadline:
        response = requests.get(
            "{0}/calls/{1}".format(BASE, execution_id),
            headers=HEADERS,
            timeout=30,
        )
 
        if response.status_code == 429:
            # Always present on a 429, in seconds.
            time.sleep(int(response.headers.get("Retry-After", interval)))
            continue
 
        if response.status_code in (502, 503, 504):
            # 1501 / 1502 are retryable upstream failures. Back off and retry.
            time.sleep(interval)
            continue
 
        if response.status_code >= 400:
            body = response.json()
            raise RuntimeError(
                "Graine error {0}: {1}".format(body["error"], body["message"])
            )
 
        call = response.json()
 
        # Prefer the server-computed flag; fall back to the set for older clients.
        if call.get("is_terminal") or normalise(call.get("status")) in TERMINAL:
            return call
 
        time.sleep(interval)
 
    raise TimeoutError(
        "Execution {0} did not finish within {1}s".format(execution_id, timeout)
    )
 
 
call = wait_for_call("exec_01HZY3Q4K7M8N9P0R1S2T3U4V5")
status = normalise(call["status"])
 
print(status, call["duration_seconds"], call["cost"]["total"])
 
if status == "completed":
    print(call["summary"])
elif status in FAILURE:
    print("Not reached:", status)
else:
    # canceled / stopped / balance-low — terminal, but not a failure.
    print("Ended without a failure outcome:", status)

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.

ValueMeaning
createdThe batch exists with its contacts loaded. Not yet running.
scheduledWaiting for its scheduled start time.
pendingDeferred by the dispatcher — outside a scheduled start, a campaign window or working hours — with a durable re-fire time stored on the batch.
in_progressCalls are being dispatched.
pausedPaused mid-run.
calls_dispatchedEvery call has been dispatched; awaiting their outcomes.
completedFinished.
failedFinished in failure.
cancelledCancelled. Two ls.
expiredThe 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:

{
  "error": 1201,
  "message": "This batch has already finished (status=completed) and cannot be resumed."
}

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:

{
  "error": 1201,
  "message": "This batch cannot be rescheduled (status=in_progress). Only a batch that has not started dispatching can be given a new start time."
}

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.

curl "https://api.graine.ai/v2/batches/batch_01HZY3Q4K7M8N9P0R1S2T3U4V5/executions?status=failed,busy,no-answer&page_size=100" \
  -H "Authorization: Bearer gat_your_api_key_here"

The counts object

A Batch object carries counts with exactly five integer buckets:

{
  "batch_id": "batch_01HZY3Q4K7M8N9P0R1S2T3U4V5",
  "status": "calls_dispatched",
  "total_contacts": 500,
  "counts": {
    "pending": 12,
    "in_flight": 43,
    "completed": 380,
    "failed": 65,
    "total": 500
  }
}

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:

BucketContact states that land here
pendingNot yet dispatched.
in_flightDispatched, ringing, in progress, retrying, or waiting on a scheduled follow-up.
completedAnswered at least once. Includes a contact whose follow-up attempts are exhausted, because a follow-up is only ever scheduled after an answered call.
failedEverything else that has finished: failed, busy, no answer, retries exhausted, or skipped.
totalEvery 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 on message. The integer is the contract; the sentence is for humans and may be reworded.
  • Retry 429, 503 and 504. Honour Retry-After on a 429; use your own backoff for 503 and 504. Do not retry 400, 401, 403, 404, 409 or 422 — the same request will fail identically.
  • 409 is sometimes retryable, sometimes not. "The call is still being placed. Retry in a moment." ships a Retry-After and means retry. "This call already ended" and "This batch has already finished" do not — go read the resource instead.
  • Treat 402 as a stop signal. A dial will keep failing until the wallet is funded. Do not loop on it.
  • Watch X-RateLimit-Remaining on successes, not just on 429s. 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.

curl -s https://api.graine.ai/v2/health
{ "status": "ok", "checks": { "mongodb": true, "redis": true } }
ResponseMeaning
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.