REST endpoints
The key REST endpoints — agents, web/trial calls, numbers, calls, messages, and wallet/usage.
No MCP client? Every capability is also a plain JSON REST endpoint under /v1.
Authenticate with the same scoped sky_ key as a Bearer token. The
OpenAPI spec
(openapi.json) is the
machine-readable source of truth; this is the human map. To try requests, use
the Skysay API Postman workspace. For copy-pasteable requests and responses from a new key to a
placed call, follow the Quickstart with curl. Failed
requests are described in Errors.
A workspace key never needs organization_id: leave it out of query strings
and request bodies and the key's own workspace is used. See
Authentication & keys.
Building a customer application?
Start with Customer applications, then connect live data through Integrations. The Generated API reference lists the current customer SDK operation set from the canonical OpenAPI snapshot.
Bounded list pages
The following growing lists return an object, not a bare array. Read the
named item array and the pagination object together:
{
"agents": [{ "id": "agent_example" }],
"pagination": {
"limit": 50,
"returned": 1,
"has_more": false,
"next_cursor": null
}
}returned counts this page, not the entire collection. A page is complete only
when has_more is false. Otherwise send next_cursor unchanged as the next
request's cursor, keeping the same organization, project and other filters.
limit must be a whole number between 1 and the route's maximum; out-of-range
and malformed limits or cursors return 400, not a silently shortened page.
All pages are newest first, with a stable creation-order tie break. A walk
does not repeat rows or skip older rows that remain present throughout it;
it is not a cross-request database snapshot and does not include newly added
rows ahead of its cursor.
| GET path | Item key | Default limit | Maximum |
|---|---|---|---|
/v1/agents | agents | 50 | 200 |
/v1/numbers | numbers | 50 | 100 |
/v1/agent-endpoints | agent_endpoints | Required | 200 |
/v1/number-requests | number_requests | 50 | 100 |
/v1/phone-servers | phone_servers | 50 | 200 |
/v1/trial-calls | trial_calls | 50 | 100 |
/v1/api-keys | api_keys | 50 | 200 |
/v1/customer-onboarding | customer_onboarding | 50 | 200 |
/v1/gateway-deployments | gateway_deployments | 50 | 200 |
/v1/end-users | end_users | 50 | 200 |
/v1/conversations | conversations | 50 | 200 |
/v1/messages | messages | 100 | 500 |
/v1/usage-records | usage_records | 100 | 500 |
/v1/wallet/ledger | entries | 100 | 500 |
/v1/billing/invoices | invoices | 100 | 500 |
/v1/audit-events | audit_events | 100 | 500 |
/v1/webhook-deliveries | deliveries | 50 | 500 |
The ledger retains its currency and totals, but totals cover this page's
entries only; use GET /v1/wallet for the wallet balance. Invoice pages keep
their currency. Delivery pages keep event_catalog and retry_policy.
Number-request list items do not embed a provider mutation's audit history.
GET /v1/calls retains its existing array and paging-header contract; call
event responses are not changed by these list envelopes.
Migrating a list consumer
This replaces the previous bare-array contract on the listed routes. Do not
interpret an unexpected response shape or a failed request as an empty list.
In particular, a find-or-create client must finish a strict page walk before
deciding a matching object does not exist; otherwise it can create duplicates.
GET /v1/agent-endpoints requires an explicit limit so an unupdated client
receives 400 instead of misreading the new envelope as no endpoints.
For each successful response, require an item array, positive integer limit,
integer returned equal to the array length, boolean has_more, and a
nonempty string next_cursor while more pages remain (otherwise null).
Refuse repeated cursors and stop with an error if your own page budget is
exhausted; never act on a partial collection as if it were complete. Read a
known resource by its ID instead of searching only the first page. Keep
project checks when replacing a project-filtered list with an ID lookup.
Agents
| Method | Path | Purpose |
|---|---|---|
POST | /v1/agents | Create an agent (external_audio, external_text, hosted). |
GET | /v1/workspace-context | Discover the key's own organization and a bounded project page. |
GET PATCH | /v1/agents/{id}/voice-stack | Read or set the hosted agent's STT/LLM/TTS stack. |
GET | /v1/agents/{id}/workflow | Read a draft/revision and bounded published-version summary before editing or publishing. |
GET PUT | /v1/agents/{id}/variables | Read or replace the agent's call variables, SMS templates and caller lookup. |
POST | /v1/agents/{id}/messages | Send one of the agent's published SMS templates (project-scoped). |
Bootstrap and configure a hosted agent
Start a machine client with GET /v1/workspace-context and account:read.
The bearer key decides the organization; this route does not accept an
organization_id selector. It returns only the safe organization id/name plus
a stable, explicitly bounded page of project id/name/environment values. Use
the returned page metadata to continue discovery. It also works for a new
workspace with no agents.
For a hosted agent, GET /v1/agents/{id}/voice-stack needs agents:read and
PATCH /v1/agents/{id}/voice-stack needs agents:write. Select platform-managed
STT, model, and TTS components by their catalog selectors. For BYOK, first
create the credential through the separate write-only hosted-AI credential API,
then supply only that existing credential reference in
component_credential_bindings. Voice-stack reads never return a secret,
ciphertext, credential-binding ID, or provider credential; unsupported
component, language, and credential combinations are rejected on update.
Voice settings belong to the voice-stack route
PATCH /v1/agents/{id} edits the agent's own fields: its name, status, webhook
and audio-stream URLs, prompt, first message, mode, tools, metadata and
recording flag. It does not save voice settings.
These six names belong to PATCH /v1/agents/{id}/voice-stack (MCP tool
set_agent_voice_stack):
| Setting | What it configures |
|---|---|
turn_taking | interruption sensitivity, endpointing, and the automatic call limits |
voice_polish | end-of-call phrase, delivery profile, acknowledgements, tool-wait |
audio_environment | the pinned ambience preset |
follow_caller | the automatic multilingual policy |
languages | the selected language set |
language_profiles | per-locale voice, transcriber and greeting overrides |
Sending any of them to PATCH /v1/agents/{id} returns 400 naming the route
that does save them. Until this release the same request returned 200 and
silently discarded the setting, so a limit you believed was saved was not, and
the call then ran on the platform default. If you have code that sets
turn_taking through the plain agent route and appears to succeed, it has never
been taking effect; move it to the voice-stack route.
The MCP tool update_agent is the same route and behaves the same way: it is
documented as applying "the same generic PATCH fields and validation as
PATCH /v1/agents/{agent_id}", and from this release that includes the refusal.
An agent script that sets turn_taking through update_agent now gets an error
where it used to get a success it should never have had.
Unrelated fields your client happens to send are still accepted and ignored, as before. Only these six recognised-but-misplaced names are refused — they are not "unknown fields", so REST's no-op compatibility for genuinely unknown keys does not cover them.
A voice selection must name an exact voice
PATCH /v1/agents/{id}/voice-stack returns 400 if you send a TTS provider
and model without a voice_id, whenever that provider has voices to choose
from. The body names the language and says why:
{
"error": "BadRequestError",
"message": "Choose a voice for German.",
"language": "de",
"reason": "voice_id_missing",
"blockers": [{ "code": "voice_id_missing", "scope": "agent", "detail": "" }]
}Branch on reason or the blockers codes, not on the message: the codes are
the same ones call admission and readiness report, and the message can change
between releases.
Until this release such a save could return 200 while storing a stack with no
voice. Nothing reported a problem until the agent was called, and the call then
failed before the caller heard anything. Refusing the save is how you find out
at the moment you make the change rather than on the next live call.
Two things are unchanged. Saving only a transcriber, with no voice selection in the request, is unaffected. And editing an older agent whose stored voice predates this rule still works: that repair path resolves the provider's own default voice rather than refusing your edit.
Before a workflow PATCH or publish, use
GET /v1/agents/{id}/workflow?version_limit=20&version_offset=0 with
agents:read. It gives you the editable draft/revision, validation, safe
canvas layout, current published-version metadata, and a newest-first bounded
version page to compare. An existing agent with no draft returns
workflow_state: "not_created"; the read never creates a blank workflow,
compiles, repairs, calls AI/providers, or writes state.
The Generated API reference and live OpenAPI document carry the exact request and response contracts.
Audio environments
| Method | Path | Purpose |
|---|---|---|
GET | /v1/audio-environments | Read the immutable, versioned licensed ambience catalogue for the workspace. |
GET /v1/audio-environments needs account:read. It is a local catalogue
read: it does not contact a voice provider, call a carrier, change a selected
environment, or return audio bytes. See Audio environments
for the outgoing-ambience and caller-input contracts. A 503 means the
packaged catalogue failed its integrity check; retry rather than inventing a
preset or substituting another version.
Calls
| Method | Path | Purpose |
|---|---|---|
POST | /v1/trial-calls | Create an offline fixture with synthetic_only: true; not a live phone trial. |
POST | /v1/calls | Place a policy-gated outbound call. |
GET | /v1/calls | List calls. |
GET | /v1/calls/{id} | Call status + recording state. |
GET | /v1/calls/stats | Call volume, outcome, talk time and spend over one or more date windows. |
POST | /v1/calls/{id}/cancel | Withdraw a call that is still queued, or one held for review, before anything dials. |
POST | /v1/calls/{id}/hangup | Ask a live call to hang up. |
What a call-stats window counts
GET /v1/calls/stats needs calls:read. Each window you ask for comes back
with since and until plus five integers:
| Field | What it counts |
|---|---|
calls | Every call created in the window, including ones a policy blocked and ones that were never billable. |
answered | Calls somebody was on: completed, or answered, in progress, or with media started right now. Never a call the platform merely accepted. |
failed | Calls that ended with nobody on them: failed, busy, no answer, canceled, refused by a policy or live-call gate, or held for review. |
talk_seconds | Total talk time across the window's recorded calls. |
spend_minor | The wallet reservation estimate made when each call was admitted, in the wallet's minor units. This is not settled billing. Calls that never cost anything are left out: refused, held for review, or canceled before dialling. |
answered and failed do not have to add up to calls. A call that is still
dialing or still queued is in neither, and a call waiting on operator review is
in neither. Read the two figures the endpoint gives you; do not derive one from
calls minus the other.
End a live call
POST /v1/calls/{call_id}/hangup needs calls:write and takes an empty body.
It is the request the Hang up button on the Calls page sends.
202means the request was recorded, not that the call is over. The worker holding the media session tears the call down, and the call then completes through its ordinary path withended_reasonhangup_requested, unless it had already started ending another way, in which case that reason is reported. Watch the call (orcall.ended) for the ending; do not treat the202as one.- Asking again returns the original request (
already_requested: true, the samehangup_requested_at). Until a worker acknowledges, the request readsstatuspending. If the worker reports that the teardown failed, the call is still up and a further request starts a fresh attempt; after three failed attempts the endpoint refuses, so the call is escalated rather than retried. 409when the call is not live: it has already finished, it is still queued for origination (usePOST /v1/calls/{call_id}/cancel), or its teardown has already failed three times.404for a call id that does not exist, belongs to another workspace, or is a Simulations Lab run. The three answer identically, so the endpoint cannot be used to find out whether someone else's call exists.
Post-call results describes the response fields.
Numbers
| Method | Path | Purpose |
|---|---|---|
GET | /v1/coverage | Country coverage matrix. |
POST | /v1/available-numbers/search | Load exact numbers the carrier can sell now, optionally by area code or region. Nothing is reserved or charged. |
GET | /v1/available-numbers | List the exact numbers currently loaded, with where each one is. |
POST | /v1/number-requests | Request a managed number (operator-approved). |
GET | /v1/numbers | List numbers and registration status. |
GET PUT | /v1/numbers/{id}/rules | Read / update number rules. |
How long a regulated number takes to activate
Some countries require the carrier to approve a registration before a number carries traffic. Two fields describe that wait, and both are deliberately silent rather than guessing.
activation_estimate, on the requirements for a country and number type, is not
always a duration, and must be checked before it is displayed as one. It holds a
duration only where the number needs a carrier registration and the carrier
publishes an activation time for that market: that published figure, passed
through unchanged, usually measured in weeks. Where registration is required and
the carrier publishes nothing — Estonia among them — it is empty, because
Skysay does not substitute a figure of its own.
The other two values are routing states, not times:
instant_after_payment_and_policy_checks— the number needs no carrier registration at all.manual_review— whether it needs one is not yet established.
Either describes the market, never your particular request.
The headline on GET /v1/end-users/{end_user_profile_id}/registration-status
says how many of that profile's number registrations Skysay has submitted to
the carrier. It is empty before anything has been submitted, and empty while a
refusal or a request for more information is outstanding, because that is what
needs your attention instead. The per-number headline on
GET /v1/numbers/{number_id}/activation reports the same submission for one
number and adds the carrier's published activation time when that market has one.
An empty headline is not a missing value: it means nothing has been submitted
that it could report. For the stage a request has actually reached, read
regulatory_status on the number, and blocking_items on the activation
endpoint for what it is waiting on.
next_action on the activation endpoint is held to the same rule. It repeats the
first blocking item when there is one. With nothing blocking it tells you to wait
for carrier approval only when the registration really is open at the carrier;
where nothing has been sent it says so, and where a registration exists but is
not showing as open it says that instead of guessing either way.
Messages
| Method | Path | Purpose |
|---|---|---|
GET | /v1/conversations | List SMS conversations. |
POST | /v1/messages | Send a policy-gated SMS. |
Capabilities
Outbound calls and SMS authorize against the workspace's capabilities record. Machine clients can inspect that record, manage optional destination restrictions, or request higher limits with these commands. See Policy & outbound for what each field means.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/organizations/{organization_id}/capabilities | The record, its calling rules, the selectable profiles, and live usage against its caps. |
POST | /v1/organizations/{organization_id}/capabilities | Request or replace the selection. profiles is required, and so is a destination choice: all_countries: true or a countries allowlist. |
POST | /v1/organizations/{organization_id}/capabilities/accept-policy | Accept a policy version without changing what the record authorizes. |
POST | /v1/organizations/{organization_id}/capabilities/destination-restrictions | Save optional customer narrowing with the current destination_revision; countries: null removes only this extra restriction. |
POST | /v1/organizations/{organization_id}/capabilities/limit-requests | Request higher limits with a reason; does not change caps, prices or wallet funds. |
For an explicit profile-selection request, the destination choice is
all_countries: true (every destination
except sanctioned countries) or countries naming at least one ISO 3166-1
alpha-2 code. A request with neither, with an empty list, or with an entry
that is not a two-letter code once normalized answers 400, names the entry,
and writes nothing. The check is on shape, not on membership of the assigned
ISO list, so a well-formed but unassigned code is stored as sent and then
matches no destination — see
Destination controls.
Wallet & usage
| Method | Path | Purpose |
|---|---|---|
GET | /v1/wallet | Wallet balance, billing settings, top-up options, low-balance readiness. |
The wallet's purchased availability and browser-only promotional credit are separate. See Billing and browser credit before treating a balance as available phone spending.
Activation checklist
| Method | Path | Purpose |
|---|---|---|
GET | /v1/activation-state | The setup steps, each with complete and next_action, and the first incomplete step as next_step. |
Pass project_id to read one project; leave it out for the whole workspace.
metrics holds exact totals for that scope: agents, trial_calls,
numbers, number_requests, calls (live calls; simulations are not
counted) and sms_conversations, plus available_balance_minor. Every
count is the full total, however many records the workspace holds.
Keys & scopes are identical to MCP
REST and MCP share the same scoped keys and the same fail-closed scope checks. See Authentication & keys.