Skip to main content
The Vocily REST API lets your backend do the whole loop over HTTP: build and configure an agent, publish a version and put it live, place outbound calls, and read back calls, chats and what the agent remembered - with the transcript, recording, cost and analysis for each. Pair it with webhooks to get call events pushed to you as they happen instead of polling.
Some things stay in the dashboard, by design. Creating an API key, buying or connecting a phone number, setting an agent’s webhook URL, connecting WhatsApp and its templates, building HTTP tools, and running batch campaigns. Each one either spends money, signs an agreement, or hands us a third party’s credentials.
The API is server-side only - authenticate with a secret API key. Never ship a key in a browser, mobile app, or anything a customer can view.

Base URL

Every endpoint below is relative to this host, e.g. POST https://api.vocily.ai/v1/calls.

Authentication

Send your key as a Bearer token:
Create and manage keys in the dashboard under Developers → API Keys. The full secret is shown once, at creation - store it somewhere safe.

What a key can do

There is nothing to configure. Every key reaches the whole public API for the one workspace it belongs to: place calls, read calls and chats with their transcripts and recordings, list agents and phone numbers, and manage knowledge bases. A key cannot reach anything outside that surface. Account settings, billing, team members and key management itself are dashboard-only and answer 403 to any key, so a leaked key cannot change your account or mint another key. To cut off a key, revoke it in the dashboard; it stops working immediately.
Keys are workspace-scoped: a key issued in one workspace can never see another workspace’s calls or agents, whatever the request says. There is no header for choosing one - the key decides. X-Vocily-Workspace and X-Vocily-Org belong to the dashboard’s own session; sent with a key they are ignored when they agree with it and refused with 403 when they do not.

The endpoints

Place a call

POST /v1/calls

List calls

GET /v1/calls

Get a call

GET /v1/calls/{id}

Get a recording

GET /v1/calls/{id}/recording

List chats

GET /v1/chats - widget + web text conversations

List agents

GET /v1/agents

List phone numbers

GET /v1/numbers

Knowledge bases

Full CRUD + attach to an agent

Webhooks

Get call events pushed to you

Voice and text are separate

A call is a voice conversation - a phone leg, or browser audio from your embedded widget. A chat is a text conversation. They are separate resources because they carry different data and are billed differently: Two fields tell you where a conversation came from:
  • direction on a call - inbound, outbound, or web for a call that happened in a browser rather than on a phone. Filter with ?direction=, which takes the same three values. A web call carries no numbers.
  • origin on both - widget, dashboard, api, batch, inbound or demo. This is how you separate real end-user traffic from tests you ran yourself in the Vocily dashboard: ?origin=widget,inbound. It can be null on older conversations, meaning unknown.
New values will be added to direction, origin and status over time. Treat them as open sets - never write an exhaustive switch that assumes today’s list is final.
WhatsApp conversations are not in either resource; they are managed in the dashboard.

Pagination

List endpoints return a cursor-paginated envelope, not a bare array:
To fetch the next page, pass the response’s next_cursor back as the cursor query param. When has_more is false, next_cursor is null and you’ve reached the end. Cursors are opaque - don’t parse or construct them. A cursor from a differently sorted list is refused with invalid_cursor rather than quietly handing you page 1. Which lists page: calls, chats, agents, an agent’s versions, its deploy history, its remembered callers, knowledge bases, and the analysis library. These are the lists that grow without limit, so a cursor is the only way to read all of one. Which do not: numbers, WhatsApp numbers, the memory changes for a single call, and the catalogues (models, voices, transcribers, languages, TTS capabilities). Each is bounded by something real - what your workspace holds, or what we offer - and returns a bare array, so building a picker stays one request.
No ?skip= or ?offset=. Both existed until 2026-09-25 and both drift: a row written while you page can appear on two pages or on none. A cursor names the boundary instead, so new rows land on page 1 and the point you are reading from does not move.

Idempotency

POST /v1/calls accepts an Idempotency-Key header (required for API-key callers). Retrying a request with the same key returns the original call instead of placing a second one - safe against network timeouts. Use a fresh UUID per distinct call.

Rate limits

Requests are limited to 300 per minute per key (moving window). Over the limit you get:
Honor Retry-After (seconds) rather than retrying in a tight loop.

Concurrency headers

Voice concurrency is a shared pool per organization. Every response carries your current usage so you can self-pace bulk dialing:
For large volumes, use scheduled batches in the dashboard rather than a tight POST /v1/calls loop.

Errors

Errors use standard HTTP status codes. The body always carries a detail, and usually a top-level code:
detail is an object: code is the machine-readable reason and message is a sentence safe to log. Every refusal a public endpoint raises carries both. Two responses are shaped by the framework before any endpoint runs, and carry a plain-string detail instead: a 404 for a path that does not exist, and a 405 for a method an existing path does not accept. Both still carry the top-level code (NOT_FOUND, METHOD_NOT_ALLOWED), which is what to branch on — and neither is something a correct client produces. The top-level code is normally the class of failure derived from the status - BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, VALIDATION_ERROR, SERVICE_UNAVAILABLE - but on a 429 and on a malformed-parameter 400 it is the reason itself. So read the reason with one rule that covers every response:
Branch on that. Never branch on message text - we reword messages without notice.
One response does not follow the shape above. A 429 is flat, { "code": "rate_limited" }, with Retry-After and the X-RateLimit-* headers carrying the rest.

Validation errors

A request the API cannot read - a field of the wrong type, out of range, missing, or one we do not accept - is a 422 in the same envelope, with reason validation_error. message names the first problem; errors lists every problem, so one round trip is enough to fix a malformed body:
field is a path from the root of your request - voice.speed, variables[0].key, or query.limit for a query parameter. type is a stable machine code (missing, extra_forbidden, less_than_equal, …); switch on it rather than on message.

Reason codes

Read these from detail.code, except the two marked (top-level).
A malformed id (a partial UUID, or one with a stray character such as a trailing }) never crashes the API. On agent, version and custom-analysis routes it is a 422 validation_error whose errors[0].field names the id; on call and chat routes it reads as “not found” (404, call_not_found); anywhere else it is a 400 with top-level code = invalid_request.

Versioning & request IDs

  • Versioning - the REST surface is pinned by the /v1 path prefix. Response and webhook payloads are additive within a version: fields may be added, never removed or renamed. Breaking changes ship under a new dated version.
  • Request IDs - every response includes an X-Request-ID header. Include it when contacting support so we can trace the exact request.