Outbound Calls API
POST /v1/calls tells a Voicetta agent to ring a phone number.
Use it to start a call from your own product: after a form submission, when a lead lands in your CRM, on a schedule, or as a step in a workflow.
This is the only endpoint that writes. Everything else in the API reads.
Before your first call
You need three things.
- An API key with permission to place calls. Go to Settings → Developers, create a key, and tick Place outbound calls. Keys that only read cannot dial.
- An agent ID. Copy it from Agent in the dashboard, or call
GET /v1/assistants. - A phone number enabled for outbound calls. Set this up on the agent's number in the dashboard.
Existing API keys stay read-only. Being able to read a transcript and being able to ring a phone are separate decisions, so granting the first never grants the second.
Place a call
curl -X POST "https://api.voicetta.com/v1/calls" \
-H "Authorization: Bearer vk_live_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-8842" \
-d '{
"assistant_id": "c4f2a8e1-9b7d-4e35-a612-7f8c3d5b9e04",
"to": "+14155559999",
"customer_name": "Sarah Mitchell",
"call_variables": {
"hotel_name": "The Pine Ridge Inn",
"check_in_date": "October 14"
},
"metadata": {
"crm_id": "cust_123",
"campaign": "autumn-reactivation"
}
}'You get 201 Created with a call_id:
{
"call": {
"call_id": "9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11",
"workspace_id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
"assistant_id": "c4f2a8e1-9b7d-4e35-a612-7f8c3d5b9e04",
"assistant_name": "Front desk",
"direction": "outbound",
"status": "queued",
"duration_seconds": 0,
"started_at": "2026-09-18T09:14:02Z",
"customer": {
"name": "Sarah Mitchell",
"phone": "+14155559999"
},
"call_variables": {
"hotel_name": "The Pine Ridge Inn",
"check_in_date": "October 14"
},
"metadata": {
"crm_id": "cust_123",
"campaign": "autumn-reactivation"
}
}
}The response comes back when the call is accepted, not when it connects. Dialling, ringing and talking all happen afterwards.
Request fields
| Field | Type | Required | Meaning |
|---|---|---|---|
assistant_id | UUID | Yes | The agent that will handle the call |
to | string | Yes | Number to call, E.164, e.g. +14155559999 |
from | string | No | Number to call from, E.164 |
customer_name | string | No | Who is being called. Available to the prompt as {{participant_name}} |
call_variables | object | No | Values for this one call, substituted into the prompt |
metadata | object | No | Your own JSON, stored and returned unchanged |
from is optional when the agent has exactly one number enabled for outbound calls, because then there is nothing to choose. With two or more you have to say which, since guessing would place the call from whichever number happened to sort first.
metadata is never interpreted by Voicetta. Put your CRM id, your campaign name, or whatever you need to match the call back to your own records. It comes back on the call object and on every webhook for that call. At most 8 KB.
call_variables fills the {{placeholders}} in the agent's prompt. That has its own page: Dynamic Variables.
Following the call
One call_id identifies the call for its whole life — while it is queued, while it is talking, and afterwards as the conversation that holds the transcript.
Poll it:
curl -H "Authorization: Bearer vk_live_your_key_here" \
"https://api.voicetta.com/v1/calls/9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11"You get the same object shape throughout, so one handler works from queued to ended. The blocks that cannot exist yet are simply absent: there is no transcript until there has been a conversation.
Statuses
| Status | Meaning |
|---|---|
queued | Accepted, waiting for an agent to pick it up |
in_progress | Dialling, ringing or talking |
ended | The call happened and a transcript has been saved |
error | The call did not happen and never will. See end_reason |
queued and in_progress always resolve. A call that stops making progress is moved to error rather than left waiting forever, so your client never polls indefinitely.
Or use webhooks instead
Polling works, but webhooks are less work.
| Event | Fires |
|---|---|
call.started | The call begins dialling |
call.completed | The call ended and post-call processing finished |
Both carry the same call_id, so you can correlate them without storing anything extra. Enable them in Settings → Developers.
call.started carries who is being called, but no transcript, summary, outcome or recording — those are all products of the call having ended, and arrive with call.completed. See the Webhook Reference.
Retries and duplicate calls
A call spends money and rings a real person, so a retry after a timeout must not dial twice.
Send an Idempotency-Key header with a value unique to the call you intend to place:
Idempotency-Key: lead-8842If we have already seen that key in your workspace, you get 201 with the original call and an Idempotent-Replay: true header. No second call is placed.
The same status and body either way, so a client that ignores the header still behaves correctly, and one that reads it can tell the retry was absorbed.
Pick something stable and meaningful: an order number, a CRM record id, a job id. Not a fresh UUID per attempt — that defeats the point. Keys are stored hashed, so a key containing customer data does not linger in our tables.
Errors
The status code tells you whether to fix the request, wait, or give up.
| Status | Meaning | What to do |
|---|---|---|
400 | Bad destination, missing from, outbound not enabled, or invalid call_variables | Fix and resend |
401 | Missing or invalid API key | Check the key |
403 | Key cannot place calls, or that country is not permitted | Tick the scope, or check restrictions |
404 | No such agent or number in this workspace | Check the id |
409 | Wallet empty, or the agent is at its concurrency limit | Top up, or retry later |
429 | Rate limit exceeded | Back off and retry |
502 | The telephony provider refused the call | Retry; it is not your request |
A 409 and a 429 are worth retrying. A 400 is not — the same request will fail the same way.
Validation errors name the field:
{
"detail": [
"call_variables key 'guest-name' must start with a letter and contain only letters, digits and underscores"
]
}Every problem is reported at once, so you do not need one request per mistake.
Rate limits
| Limit | Scope |
|---|---|
| 200 calls per hour | Per workspace |
| 10 requests per second | Burst protection |
Counted per workspace rather than per caller, because an API key has no user behind it and a serverless client arrives from a new IP every time.
Country restrictions
Calls are allowed within the country of the originating number. A 403 with country_not_allowed means the destination is outside it. See Calling Restrictions.
Test calls
Calls placed through the API are real and billed like any other call. There is no dry-run mode — use one of your own numbers as to.
List your agents
GET /v1/assistants returns everything you need to place a call, so a new integration only has to make one request first.
curl -H "Authorization: Bearer vk_live_your_key_here" \
"https://api.voicetta.com/v1/assistants"{
"assistants": [
{
"assistant_id": "c4f2a8e1-9b7d-4e35-a612-7f8c3d5b9e04",
"assistant_name": "Front desk",
"workspace_id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
"phone_numbers": [
{
"phone_number": "+14155551234",
"country": "US",
"outbound_enabled": true
}
],
"call_variables": ["hotel_name", "check_in_date"]
}
]
}call_variables here is read from the agent's actual prompt, so it cannot drift from what the agent really says. Variables Voicetta fills in itself are not listed, because passing one would be rejected.
Reading agents needs only a normal API key. The write scope is for POST /v1/calls.