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
POST https://api.vocily.ai/v1/calls.
Authentication
Send your key as a Bearer token: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 answer403 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/callsList calls
GET /v1/callsGet a call
GET /v1/calls/{id}Get a recording
GET /v1/calls/{id}/recordingList chats
GET /v1/chats - widget + web text conversationsList agents
GET /v1/agentsList phone numbers
GET /v1/numbersKnowledge 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:
directionon a call -inbound,outbound, orwebfor 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.originon both -widget,dashboard,api,batch,inboundordemo. This is how you separate real end-user traffic from tests you ran yourself in the Vocily dashboard:?origin=widget,inbound. It can benullon 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.Pagination
List endpoints return a cursor-paginated envelope, not a bare array: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: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:POST /v1/calls
loop.
Errors
Errors use standard HTTP status codes. The body always carries adetail, 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:
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 a422 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 fromdetail.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
/v1path 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-IDheader. Include it when contacting support so we can trace the exact request.