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 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, 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 error key.

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

StatusMeaningWhen you see it on this API
200 OKSuccess 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 CreatedA 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 AcceptedWork 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 ContentSuccess, 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 FoundRedirect.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 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, call, campaign, webhook, knowledge base or key in your organization — or no such path.Error 1200.
405 Method Not AllowedThe path exists, but not with this method.Error 1000. The Allow header names a method the path does accept.
409 ConflictThe 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 RequiredAn 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 LargeAn 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 TypeA file is not a type a knowledge base can read.Error 1000. Upload PDF, PNG, JPG or JPEG.
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 every line was busy and the call could not be queued.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 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 UnavailableA service the request depends on is temporarily unavailable.Error 1501.
504 Gateway TimeoutA 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

CodeHTTPMeaning
1000400Malformed or contradictory input the schema could not reject. Also 405, 411, 413 and 415 — see above.
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 resource in this organization, or no such path.
1201409The resource's state does not allow the action.
1202409The agent is switched off, so it takes no new calls.
1203409The agent was deleted, so it takes no new calls.
1204409A workflow's Before a call is placed holds this agent's calls, and the number has had its calls for now. Carries Retry-After.
1300429Per-organization request rate exceeded, or no line free and the call could not be queued.
1400402The wallet cannot fund the call.
1500500Unhandled exception.
1501503A service the request depends on is temporarily unavailable.
1502504A 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.

{
  "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': 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.

{
  "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 that is not a recognised key, or a key whose status is not active.

{
  "error": 1100,
  "message": "Missing bearer token. Send Authorization: Bearer gat_..."
}
{
  "error": 1100,
  "message": "Invalid API key. A key looks like 'gat_' followed by 43 characters; check yours was copied in full."
}
{
  "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 before anything else, so the answer is immediate.

{
  "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 optional on most routes, and 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 for this action. Missing scope: calls:write."
}

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.

{
  "error": 1200,
  "message": "Call not found."
}
{
  "error": 1200,
  "message": "Agent not found."
}
{
  "error": 1200,
  "message": "Campaign 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 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.

{
  "error": 1201,
  "message": "This campaign is completed and cannot be paused. A completed campaign never dials again; create a new campaign instead."
}
{
  "error": 1201,
  "message": "You cannot revoke the key you are authenticating with."
}
{
  "error": 1201,
  "message": "This workspace requires a passing test run before an agent goes live, and this agent has none. Run its tests in Evals, then try again."
}

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.

{
  "error": 1202,
  "message": "This agent is switched off, so it takes no new calls. Switch it on to call with it again."
}

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.

{
  "error": 1203,
  "message": "This agent was deleted, so it takes no new calls."
}

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.

{
  "error": 1204,
  "message": "This number has had its calls for now: a workflow holds this agent's calls to it. It can be called again after 2026-11-03T20:32:25Z."
}

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:

{
  "error": 1300,
  "message": "Rate limit exceeded: 1200 requests per minute for this organization. Retry in 17s."
}
GroupLimitApplies to
Everything else1000 / minuteEvery route not listed below.
Placing calls600 / minute (500 sustained)POST /v2/calls, POST /v2/calls/{execution_id}/stop, POST /v2/calls/{execution_id}/transfer, POST /v2/campaigns.
Reading calls600 / 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 uploads20 / minutePOST /v2/knowledge-bases, POST /v2/knowledge-bases/{collection}/documents.
Transliteration60 / minutePOST /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:

{
  "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.

{
  "error": 1400,
  "message": "Insufficient credits to place this 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.

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

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:

{
  "error": 1501,
  "message": "Call storage is temporarily unavailable."
}

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-Limit: 2000
X-RateLimit-Remaining: 1983
X-RateLimit-Reset: 41

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.

ValuePhaseMeaning
queuedin-flightHanded 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").
scheduledin-flightBooked 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.
rescheduledin-flightKept for compatibility. No call placed today is stored with it.
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. error.message says why when a reason was recorded; one that begins agent_unavailable means the person answered and the voice agent never joined.
busyterminal, failureThe far end was busy.
no-answerterminal, failureRang out unanswered. Also the status usually recorded when a voicemail box picks up — see below.
canceledterminalCancelled. One l — see the spelling note.
stoppedterminalKept 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-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.
do_not_callterminalThe 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:

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

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 campaign retries act on:

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

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}/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: changing either would break every integration that already matches on it.

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

curl https://api.graine.ai/v2/calls/8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88 \
  -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 temporary. 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("8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88")
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.
pendingWaiting — outside a scheduled start, a campaign window or working hours — with the time it will start again 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.

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:

{
  "error": 1201,
  "message": "This campaign is completed and cannot be resumed. A completed campaign never dials again; create a new campaign instead."
}

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.

curl "https://api.graine.ai/v2/calls?batch_id=7d1f0a52-1a2b-4c3d-9e8f-0a1b2c3d4e5f&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 counts:

{
  "batch_id": "7d1f0a52-1a2b-4c3d-9e8f-0a1b2c3d4e5f",
  "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. Each contact's state maps into them as follows:

CountContact states that land here
pendingNot yet dispatched.
in_flightDispatched, ringing, in progress, retrying, or waiting on a scheduled follow-up.
completedAnswered at least once, on any attempt.
failedEverything 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.
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 campaign is completed" 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 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.

curl -s https://api.graine.ai/v2/health
{ "status": "ok", "checks": { … one entry per part of the service … } }

The body also carries a checks object with one boolean per dependency it tested.

ResponseMeaning
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.