Errors
The JSON body every failed request returns, what each HTTP status means, and which errors are safe to retry.
Every failed REST request answers with a JSON object that has an error field.
Branch on the HTTP status first, then on error. The message is written for
a person reading logs. Its wording can change between releases, so do not
match on it.
The body shapes
Most errors use one shape: error names the kind of failure and message
says what was wrong with this request.
{ "error": "BadRequestError", "message": "request body must be valid JSON" }A few errors carry a code of their own and extra fields you can act on:
error | Status | Extra fields |
|---|---|---|
not_found | 404 | path: the route does not exist. Check the method and path. |
method_not_allowed | 405 | allow, plus an Allow header listing the methods the path accepts. |
BadRequestError from PATCH /v1/agents/{id}/voice-stack | 400 | Sometimes language, reason and blockers ([{code, scope, detail}]): a language of the voice stack cannot be published. reason is the first blocker's code, for example voice_id_missing when no voice is chosen for that language; see A voice selection must name an exact voice. |
realtime_config_not_runnable | 400 | reason and blockers ([{code, scope, detail}]): the agent could not run a call; see Customer applications. |
outbound_call_queue_full | 429 | retry_after_seconds, plus a Retry-After header. |
platform_maintenance | 503 | retry_after_seconds, plus a Retry-After header. See Maintenance windows. |
WorkflowRevisionConflictError | 409 | current: the workflow draft that won; reload it before retrying. |
internal_error | 5xx | request_id. Quote it to support. |
A server error that Skysay did not anticipate never shows its internal cause. You get a fixed message and an id instead:
{
"error": "internal_error",
"message": "The server could not complete this request. Quote request_id when contacting support.",
"request_id": "a1b2c3d4e5f60718"
}The request_id is unique to that one request. Keep it in your own logs beside
the request that failed, and quote it to support so the failure can be found.
Some 5xx answers are designed rather than unexpected, and they are not this
shape: a 503 webhook_retry_requested asking a provider to redeliver, a
503 platform_maintenance during a scheduled maintenance window, or a
502 kyc_provider_error naming a verification provider's own status. Those
keep their own error code and carry no request_id.
What each status means
| Status | error | What it means | What to do |
|---|---|---|---|
400 | BadRequestError | The request is malformed or breaks a rule: body is not a JSON object, a required field is missing, a value is out of range. The message names the field. | Fix the request. Retrying it unchanged fails the same way. |
401 | UnauthorizedError | No key, a key that is not a Bearer token, or a key that is invalid or revoked. | Check the Authorization: Bearer sky_… header, or create a new key. |
402 | InsufficientBalanceError | The wallet cannot cover what the request would reserve. | Top up; see Billing. |
403 | ForbiddenError | The key lacks a scope (missing required scope: calls:read), or the request names a workspace other than the key's own. | Add the scope to the key, or remove the foreign organization_id. |
404 | NotFoundError | The id does not exist, or it belongs to another workspace. The two are deliberately indistinguishable. | Check the id and the key's workspace. |
404 | not_found | No such route. | Check the method and path. |
405 | method_not_allowed | The path exists but not with this method. | Use a method from allow. |
409 | ConflictError and others | The request conflicts with current state: an Idempotency-Key reused for a different request, a stale revision, a draft that already exists. | Read the current state, then decide. Do not blindly retry. |
413 | PayloadTooLargeError | The request body, or an uploaded verification document, is over its size limit or quota. | Send less. |
415 | UnsupportedMediaTypeError | The request has a Content-Encoding other than identity. | Send the body uncompressed. |
422 | varies | A simulation-suite request is well formed but cannot run as asked. | Read message and the extra fields. |
423 | SuspensionError | A workspace control refused the action: a spend or velocity cap, the live-call concurrency limit, a suppression, or a suspension. | Wait for the cap window, or contact support for a suspension. |
429 | outbound_call_queue_full | The outbound call queue is full. | Retry after Retry-After seconds. |
503 | platform_maintenance | Skysay is in a scheduled maintenance window and is not accepting new work. | Retry after Retry-After seconds, with the same Idempotency-Key. See Maintenance windows. |
500–504 | internal_error or a named error | Skysay could not complete the request. | Retry with backoff, using an Idempotency-Key on POST /v1/calls. If it persists, send the request_id to support. |
403 and 404 on a by-id read follow a fixed rule so a status can never
reveal which ids exist in another workspace. See
A resource outside your workspace is absent, not forbidden.
Maintenance windows
Skysay occasionally runs a short, scheduled maintenance window. While one is
open, requests that would start new work answer 503 with
"error": "platform_maintenance", a retry_after_seconds field and a
Retry-After header:
{
"error": "platform_maintenance",
"message": "Skysay is undergoing scheduled maintenance. Please retry in a few minutes.",
"retry_after_seconds": 300
}Refused during a window:
- placing a call (
POST /v1/calls) or sending an SMS (POST /v1/messages); - incoming calls to your numbers. The caller is not answered, as when a
number is suspended. Each call is recorded as failed with the reason
platform_maintenance, so it appears in your call history, and nothing is charged; - starting a browser test or Talk session, a Chat turn, or a simulation run;
- creating an agent (including through
POST /v1/agent-endpoints), deleting an agent endpoint, and changing an agent's configuration, voice stack, knowledge bases, post-call extraction or workflow, including publishing. Setting an agent toinactive, with no other change in the same request, still works; - launching or resuming a campaign.
Not affected:
- calls already in progress keep going, including their transfers and an agent's SMS sent during the call;
- queued calls and running campaigns wait. A queued call is not failed and a
campaign contact is not used up; they continue once the window closes. A
window does not extend a campaign's end time: if
ends_atpasses during a window, the campaign ends then, like any campaign whose end time arrives; - wallet top-ups and payments, delivery reports and incoming SMS;
- reading anything, and pausing or cancelling a campaign.
The same actions taken through MCP tools are refused too: the tool result is marked as failed and carries this error.
A refused request changed nothing, so retrying it after the window is safe.
Keep the same Idempotency-Key on POST /v1/calls.
Examples
A missing key:
curl -s "$SKYSAY_BASE_URL/v1/agents"{ "error": "UnauthorizedError", "message": "missing Authorization Bearer token" }A body that is not JSON:
curl -s -X POST "$SKYSAY_BASE_URL/v1/agents" \
-H "Authorization: Bearer $SKYSAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{not json'{ "error": "BadRequestError", "message": "request body must be valid JSON" }An id that is not in your workspace:
curl -s "$SKYSAY_BASE_URL/v1/agents/agent_doesnotexist" \
-H "Authorization: Bearer $SKYSAY_API_KEY"{ "error": "NotFoundError", "message": "agent not found" }Retrying safely
Retry only 429 and 5xx, with exponential backoff. A call that outbound policy refuses is not an error: POST /v1/calls answers 201 with status: "policy_blocked" or "needs_review", as described in the curl quickstart. 4xx answers describe
the request, and the same request gets the same answer. For
POST /v1/calls, always send an Idempotency-Key header: a retry with the
same key and body returns the original call instead of dialling twice. See
Customer applications.
MCP clients see the same failures as JSON-RPC tool errors, for example
missing MCP scopes: calls:write; see Scopes & permissions.