Browse docs
Calls
Outbound Calls & Queue
Try this APIPlace outbound phone calls that queue automatically when your concurrency budget is full, then poll a single id from queued to completed.
7 min read
Overview
Place outbound voice calls programmatically. When your concurrency budget is full, calls are not dropped, they are accepted into a durable queue and dialed automatically as capacity frees. This page covers placing a call, watching the queue, polling a call from "queued" all the way to "completed", and cancelling a call before it dials.
https://go.convoi.ai/backend/api/v1/publicAuthentication
Every request is authenticated with your private API key as a bearer token.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxIdentifiers & errors
Object identifiers are prefixed by type, so you can always tell a placed call from a queued one at a glance.
| Prefix | Object |
|---|---|
| call_ | A placed call with a live status. |
| qcall_ | A queued call, waiting for a free slot. |
| agt_ | Agent. |
| sqd_ | Workforce (multi-agent squad). |
| phn_ | Phone number / outbound SIP trunk. |
- Errors return a JSON body with a detail message and a standard HTTP status. See Reference, Errors for the full error shape.
- Timestamps are ISO 8601 strings in UTC.
- Responses carry X-RateLimit-* headers. A 429 means back off and retry.
Start a call
POST /api/v1/public/callsPlaces an outbound phone call. If a concurrency slot is free the call dials immediately. If the budget is full it is parked in the queue and dialed automatically as a slot frees, nothing is dropped.
Request headers
| Header | Required | Notes |
|---|---|---|
| Authorization | required | Bearer sk_live_… |
| Content-Type | required | application/json |
| Idempotency-Key | optional | Safe retries: replaying the same key + body within 24h returns the original response instead of dialing twice. Recommended, use the signup event id. |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | required | "outbound_phone". |
| agent_id | string | required* | Agent to run the call (agt_…). *Provide either agent_id or squad_id. |
| squad_id | string | alt | Dial a Workforce (sqd_…) instead of a single agent. |
| phone_number_id | string | required | Outbound SIP trunk to dial from (phn_…). |
| customer.number | string | required | Destination in E.164 (+9715…). |
| customer.name | string | optional | Contact display name. |
| metadata | object | optional | Opaque JSON echoed back on reads (≈4KB max). |
{
"type": "outbound_phone",
"agent_id": "agt_xxxxxxxx",
"phone_number_id": "phn_xxxxxxxx",
"customer": {
"number": "+971501234567",
"name": "Ali"
},
"metadata": { "signup_id": "abc123" }
}Start a call: responses
A 201 Created means a slot was free. The response is a real call (object "call") with a call_… id.
{
"id": "call_9f8e7d…",
"object": "call",
"type": "outbound_phone",
"status": "initiated",
"agent_id": "agt_xxxxxxxx",
"phone_number_id": "phn_xxxxxxxx",
"customer": { "number": "+971501234567", "name": "Ali" },
"metadata": { "signup_id": "abc123" },
"created_at": "2026-07-13T06:45:00Z",
"updated_at": "2026-07-13T06:45:00Z"
}A 202 Accepted means the budget was full. The response is a queued call (object "queued_call") with a qcall_… id. Poll it until it dials.
{
"id": "qcall_1a2b3c…",
"object": "queued_call",
"status": "queued",
"agent_id": "agt_xxxxxxxx",
"customer": { "number": "+971501234567", "name": "Ali" },
"position": 3,
"attempts": 0,
"call_id": null,
"enqueued_at": "2026-07-13T06:45:17Z",
"updated_at": "2026-07-13T06:45:17Z"
}Error responses
| Status | Meaning |
|---|---|
| 429 | Per-caller queue is full (hit max depth). Retry later. |
| 503 | Capacity reached and queuing is disabled server-side. Includes Retry-After. |
| 403 | Used a public (pk_live_) key for an outbound call. |
| 422 | Validation error (missing agent_id/squad_id or customer). |
| 401 | Missing or invalid API key. |
List queued calls
GET /api/v1/public/calls/queueReturns the calls currently waiting in your queue, oldest first, plus live-vs-limit stats. This powers a "calls waiting" dashboard view.
| Param | Type | Default | Notes |
|---|---|---|---|
| limit | integer | 50 | 1-200. Max rows returned. |
{
"object": "list",
"data": [
{
"id": "qcall_1a2b3c…",
"object": "queued_call",
"status": "queued",
"customer": { "number": "+971501234567" },
"position": 1,
"enqueued_at": "2026-07-13T06:45:17Z"
}
],
"live_calls": 2,
"concurrency_limit": 5,
"queued_count": 3
}| Field | Type | Description |
|---|---|---|
| data[].position | int | null | 1-based place in line. null once it starts dialing. |
| live_calls | int | Calls currently live against the budget (global). |
| concurrency_limit | int | Configured concurrency ceiling. |
| queued_count | int | Total calls waiting in your queue. |
Retrieve a call
GET /api/v1/public/calls/{id}Fetch the current state of a call. This endpoint accepts both id types, so the client can hold one id and poll it from "queued" all the way to "completed".
| You pass | You get back |
|---|---|
| call_… | The live call's current status. |
| qcall_… | While waiting, the queued call (status "queued"). Once dialed, transparently the resulting real call, with its call_… id and live status. |
{
"id": "call_9f8e7d…",
"object": "call",
"status": "active",
"customer": { "number": "+971501234567", "name": "Ali" },
"started_at": "2026-07-13T06:46:03Z",
"duration_seconds": 12.4,
"cost": { "amount": 0.03, "currency": "USD" },
"metadata": { "signup_id": "abc123" },
"updated_at": "2026-07-13T06:46:15Z"
}| Status | Meaning |
|---|---|
| queued | Waiting for a free slot (no call placed yet). |
| initiated | Dialing / ringing. |
| active | Connected, in progress. |
| completed | Ended normally. |
| rejected | Not connected, failed, or cancelled. |
| transferred / transfer_failed | Call was transferred (or the transfer failed). |
Cancel a queued call
DELETE /api/v1/public/calls/queue/{queue_id}Removes a call from the queue before it dials, using its qcall_… id. Returns the cancelled call with status "rejected".
| Status | Meaning |
|---|---|
| 409 | Too late, it already started dialing. Cancel the live call via DELETE /calls/{id} instead. |
| 404 | Not found or not yours. |
| 400 | Malformed id. |
Integration flow
- Create the call with POST /calls. On 201 store the call_ id; on 202 store the qcall_ id. Always send an Idempotency-Key so a retried request never double-dials.
- Poll its status with GET /calls/{id} every 3-5s using whichever id you stored. A qcall_ id auto-swaps to the real call once it dials, so you keep polling the same id.
- Stop on a terminal state: completed, rejected, transferred, or transfer_failed.
- (Optional) Show the waiting view. Poll GET /calls/queue to render queued_count, live_calls / concurrency_limit, and the list of waiting numbers.
- (Optional) Let users cancel. A cancel button on a queued row calls DELETE /calls/queue/{qcall_id}.
Example: JavaScript
// 1. Start the call (auto-queues on overflow)
const res = await fetch(`${BASE}/calls`, {
method: "POST",
headers: {
"Authorization": `Bearer ${SK}`,
"Content-Type": "application/json",
"Idempotency-Key": signupId,
},
body: JSON.stringify({
type: "outbound_phone",
agent_id: "agt_xxxxxxxx",
phone_number_id: "phn_xxxxxxxx",
customer: { number: "+971501234567", name: "Ali" },
}),
});
const call = await res.json(); // object: "call" (dialing) or "queued_call"
// 2. Poll the same id until a terminal status, works for call_ AND qcall_
const TERMINAL = ["completed", "rejected", "transferred", "transfer_failed"];
async function poll(id) {
const r = await fetch(`${BASE}/calls/${id}`, {
headers: { "Authorization": `Bearer ${SK}` },
});
const c = await r.json();
if (!TERMINAL.includes(c.status)) setTimeout(() => poll(c.id), 4000);
return c;
}
poll(call.id);Related in Calls