ConvoiAi
Browse docs

Calls

Outbound Calls & Queue

Try this API

Place 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.

httpBase URL
https://go.convoi.ai/backend/api/v1/public

Authentication

Every request is authenticated with your private API key as a bearer token.

http
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx

Identifiers & errors

Object identifiers are prefixed by type, so you can always tell a placed call from a queued one at a glance.

PrefixObject
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

http
POST /api/v1/public/calls

Places 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

HeaderRequiredNotes
AuthorizationrequiredBearer sk_live_…
Content-Typerequiredapplication/json
Idempotency-KeyoptionalSafe retries: replaying the same key + body within 24h returns the original response instead of dialing twice. Recommended, use the signup event id.

Body parameters

FieldTypeRequiredDescription
typestringrequired"outbound_phone".
agent_idstringrequired*Agent to run the call (agt_…). *Provide either agent_id or squad_id.
squad_idstringaltDial a Workforce (sqd_…) instead of a single agent.
phone_number_idstringrequiredOutbound SIP trunk to dial from (phn_…).
customer.numberstringrequiredDestination in E.164 (+9715…).
customer.namestringoptionalContact display name.
metadataobjectoptionalOpaque JSON echoed back on reads (≈4KB max).
jsonRequest body
{
  "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.

json201 Created
{
  "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.

json202 Accepted
{
  "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

StatusMeaning
429Per-caller queue is full (hit max depth). Retry later.
503Capacity reached and queuing is disabled server-side. Includes Retry-After.
403Used a public (pk_live_) key for an outbound call.
422Validation error (missing agent_id/squad_id or customer).
401Missing or invalid API key.

List queued calls

http
GET /api/v1/public/calls/queue

Returns the calls currently waiting in your queue, oldest first, plus live-vs-limit stats. This powers a "calls waiting" dashboard view.

ParamTypeDefaultNotes
limitinteger501-200. Max rows returned.
json200 OK
{
  "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
}
FieldTypeDescription
data[].positionint | null1-based place in line. null once it starts dialing.
live_callsintCalls currently live against the budget (global).
concurrency_limitintConfigured concurrency ceiling.
queued_countintTotal calls waiting in your queue.

Retrieve a call

http
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 passYou 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.
json200 OK
{
  "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"
}
StatusMeaning
queuedWaiting for a free slot (no call placed yet).
initiatedDialing / ringing.
activeConnected, in progress.
completedEnded normally.
rejectedNot connected, failed, or cancelled.
transferred / transfer_failedCall was transferred (or the transfer failed).

Cancel a queued call

http
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".

StatusMeaning
409Too late, it already started dialing. Cancel the live call via DELETE /calls/{id} instead.
404Not found or not yours.
400Malformed 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

tsfetch
// 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