--- title: "Outbound Calls API" description: "Start a phone call from your own product with POST /v1/calls. Covers the outbound_calls scope, idempotency, call status, errors, rate limits, and following a call to its transcript." publishedAt: "2026-09-18" modifiedAt: "2026-09-18" category: "Reference" tags: "Voicetta, outbound calls, REST API, POST /v1/calls, idempotency, call status, telephony, AI agent" navOrder: "36" canonical: "https://voicetta.com/developers/outbound-calls" --- # 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. 1. **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. 2. **An agent ID.** Copy it from **Agent** in the dashboard, or call `GET /v1/assistants`. 3. **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 ```bash 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`: ```json { "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](/developers/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: ```bash 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](/developers/webhooks). ## 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: ```text Idempotency-Key: lead-8842 ``` If 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: ```json { "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](/docs/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. ```bash curl -H "Authorization: Bearer vk_live_your_key_here" \ "https://api.voicetta.com/v1/assistants" ``` ```json { "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`. ## Related pages * [Dynamic Variables](/developers/dynamic-variables) * [Webhook Reference](/developers/webhooks) * [Read API Reference](/developers/api-reference) * [Quickstart](/developers/quickstart) * [Overview](/developers/overview)