Sessions & calls

Sessions & calls API

A session is a live conversation; a finished session becomes a call record. This page covers placing outbound calls, reading session state, transferring and monitoring live calls, and querying call history. For browser voice, see the Web‑call API.

These are dashboard routes — responses are wrapped as { "success": true, "data": { … } } (or { "success": false, "error": "…" }). Authenticate with an API key (X-API-Key) or a user JWT plus X-Org-Id.

Two exceptions. GET /api/sessions/{id}, GET /api/sessions/{id}/messages and GET /api/sessions/{id}/tool-invocations are user‑JWT only — an API key gets 401. To read a transcript programmatically use GET /api/v1/calls/{id} on the Integration API instead, which returns the same transcript as flat JSON. Everything else on this page accepts a key. See the credential matrix.

Place an outbound call

POST /api/sessions/initiate-call

Telephony‑dials mobileNumber with the given agent.

curl -X POST https://api.telenow.ai/api/sessions/initiate-call \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "…",
    "mobileNumber": "+14155550123",
    "firstResponse": "Hi, this is Acme calling about your order.",
    "machineDetection": "hangup"
  }'
FieldTypeNotes
agentIdUUIDRequired. Agent that runs the call
mobileNumberstringRequired. Destination in E.164
userIdUUIDOptional attribution
callTypestringOptional. "whatsapp" / "whatsapp_voice" route a Plivo WhatsApp voice call
firstResponsestringOptional. Overrides the agent's opener (the first line it speaks) for this call only, and forces the agent to speak first. Omit to use the agent's configured outbound opener
machineDetectionstring"true" (leave a voicemail) or "hangup" (drop on a machine). See Answering‑machine detection for the carrier matrix and what omitting it does
variablesobjectOptional { "name": "value" } map for the agent's context variables. Required variables that are missing/blank return 400
identifierstringOptional trusted unique id for the caller (e.g. account id). Injected into tool calls when the agent has caller identity enabled
fromNumberstringOptional caller ID, as the number itself in E.164 — e.g. "+14155550123". Must be a live number your organization owns. See Choosing the caller ID
fromNumberIdUUIDThe same choice by id, if you already have one (GET /api/v1/numbers). Sending both is a 400

Success returns sessionId, callId, callStatus, and phoneNumber.

{
  "success": true,
  "data": {
    "sessionId": "…",
    "status": "active",
    "callStatus": "ringing",
    "callId": "…",
    "phoneNumber": "+14155550123"
  }
}

Error responses (still 200‑wrapped success:false for the operational ones):

ConditionResult
Destination on the Do‑Not‑Call list403
Monthly quota reached403
Concurrency limit reached — per number, workspace or browser429 with Retry-After; body carries scope, cap, active, position, retryAfter
Agent's number already on a call409, reason: "agent_busy"
Carrier circuit breaker open (repeated failures)502, reason: "carrier_circuit_open", retryAfterSecs
Carrier refused the dial for a reason on YOUR account400 with a stable code — see below
Carrier dial rejected for anything else, or the carrier is unwell502 with the carrier error, code: "provider_error"

Carrier refusals carry a code. When the carrier refuses for something an operator has to go and fix, the answer is a 400 — not a 502 — because retrying can never clear it. The body is {success, error, code, carrier, meta}, where error is a sentence written to be shown as-is, code is stable, and meta carries {carrierStatus, carrierSaid, balance, byoc} (carrierSaid is the carrier's own words; balance is present only when the carrier account is your own, and byoc says whether it is).

codeMeaning
exotel_no_creditYour Exotel prepaid wallet is empty. Top up at my.exotel.com.
exotel_platform_no_creditThe number dials on Telenow's own carrier account, which cannot place calls. Contact support.
exotel_kyc_incompleteExotel KYC is not complete. Topping up will not clear it.
exotel_app_id_missingThe Exotel connection has no App Bazaar flow ID, so outbound calls cannot be placed (inbound still works).
exotel_caller_id_invalidThe from-number is not an ExoPhone on that Exotel account.
exotel_credentials_rejectedExotel rejected the API credentials on the connection.
exotel_not_configuredPart of the Exotel credentials is missing.
exotel_rejectedExotel refused for a reason we have no specific reading for; error carries its own words.
smartflo_no_creditYour Smartflo prepaid wallet is empty. Top up in the Smartflo portal.
smartflo_platform_no_creditThe number dials on Telenow's own carrier account, which cannot place calls. Contact support.
smartflo_kyc_incompleteSmartflo KYC/verification is not complete on the account. Topping up will not clear it.
smartflo_c2c_key_missingNo Click to Call Support API key is stored for this number, so outbound cannot be placed on it (inbound still works). Tata scopes that key per number.
smartflo_destination_not_streamingThe Click-to-Call key's Destination is not the VOICE Bot, so the call connects to something other than your agent.
smartflo_token_expiredThe Smartflo API token has expired. They are issued for a fixed 1/7/15/30/90 days and cannot be refreshed — generate a new one and re-paste it.
smartflo_credentials_rejectedSmartflo rejected the API token on the connection (wrong, revoked, or IP/scope restricted).
smartflo_caller_id_invalidThe from-number is not a DID valid on that Smartflo account.
smartflo_not_configuredPart of the Smartflo credentials is missing.
smartflo_rejectedSmartflo refused for a reason we have no specific reading for; error carries its own words.

A rate limit (429) or timeout from the carrier is not in this set — those clear on their own and keep the 502.

The same variables and identifier fields are accepted by POST /api/sessions/init-web-call (agent mode).

This endpoint places ONE call. Calling it in parallel to dial a list does not work: the per-number concurrency limit is 2 by default, so a burst of 200 requests yields a couple of calls and a pile of 429s. For bulk dialing use a campaign — one request hands over the whole list and the platform paces it, retries no-answers and reports every outcome.

Answering‑machine detection

Roughly a third of cold outbound calls reach voicemail. machineDetection tells the carrier to classify who picked up and hands the verdict to the agent, so it doesn't hold a conversation with a greeting message.

# Drop the call the moment a machine answers — cheapest, best for "is this a live person" dialing
-d '{ "agentId": "…", "mobileNumber": "+14155550123", "machineDetection": "hangup" }'

# Stay on the line and let the agent leave its voicemail message after the beep
-d '{ "agentId": "…", "mobileNumber": "+14155550123", "machineDetection": "true" }'
ValueBehaviour
"hangup"Hang up as soon as the carrier says machine. The call is billed for the ring plus a second or two of detection
"true"Wait for the greeting to finish, then speak the agent's voicemail message and hang up. Waiting for the beep costs 10–30s of call time
omittedFall back to the agent's own configuration — see below. Not the same as "no detection"

"true" also accepts "voicemail", "amd", "detect" and "on"; "hangup" also accepts "drop". Anything else is a 400 rather than a silently ignored value.

Leaving a voicemail message

machineDetection: "true" only decides that the agent stays on the line. What it says comes from the agent, not from this request — so on its own, "true" leaves nothing.

Set the message once, on the agent: Agents → your agent → Identity → Call handling → Voicemail message (answering machine). It is stored as metadata.voicemailMessage, readable from GET /agents. There is no agent‑write endpoint on the API‑key surface, so this is a dashboard step — the API only chooses whether to use it.

# The agent already has a voicemail message; this call opts into using it.
curl -X POST https://api.telenow.ai/api/sessions/initiate-call \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "agentId": "…", "mobileNumber": "+14155550123", "machineDetection": "true" }'

A machine answer with no message configured hangs up silently — that is deliberate ("blank means leave no voicemail"), not a failure, so there is no error to catch. If your voicemails are silent, check that field first.

The text is read from a snapshot taken when the call binds, not at the moment of the drop. Editing the agent while a call is ringing does not change what that call leaves on the machine — the change applies to the next call.

The agent speaks the message once, then hangs up. The call's answered_by holds the raw carrier verdict (human, machine_start, machine_end_beep, …), which is how you tell voicemail drops from live conversations in call history — it is also a CSV export column.

Two other things an agent can do with a machine verdict

Both are set in the same place (Identity → Call handling), live on telephonyConfig, and outrank whatever machineDetection you send on the call — each acts on the verdict immediately, and waiting out a beep would burn 30 seconds:

ConfigOn a machine verdictEnds the call?
ivrHangup: { "enabled": true }Hang up at once — for dialing lists where an IVR or menu is a dead endYes
callScreen: { "enabled": true, "message": "…" }Speak the message, then keep the call open — for iOS/Android call screening, where a human may still pick up after hearing itNo, the conversation continues
neitherUse the per‑call machineDetection value: "hangup" drops, otherwise leave the voicemailVoicemail drop hangs up

All three land as machine_* in answered_by; the call object does not distinguish which one ran, so if you need that split, key off the agent's configuration.

callScreen with an empty message falls back to a generic English line, so set one explicitly for non‑English agents. Precedence is ivrHangupcallScreen → the per‑call value; only one runs, and only on the first verdict (carriers retry AMD webhooks, and duplicates are ignored).

Omitting the field does not mean AMD is off. The agent's own settings arm it: a configured voicemail message, Call Screen, or IVR Hangup each turn detection on by themselves, because none of them is reachable without a machine verdict. Sending "true"/"hangup" overrides for this call only; the agent's screening config still wins over a per‑call value when the two disagree (screening acts on the verdict immediately, and waiting for a beep would burn 30 seconds of a live call).

To turn detection off for a call, leave machineDetection out and clear the agent's voicemail message — there is no per‑call "off".

Calls that never connect at all (no-answer, busy, failed) are zero‑rated — you are not charged a platform fee for ring time.

CarriermachineDetection
Plivo, Vobiz, VonageSupported
TwilioSupported. "true" maps to DetectMessageEnd so the drop lands on the recording; "hangup" maps to Enable, which fires as soon as the machine starts
ExotelNo per‑call arm. Verdicts arrive only if AnsweredBy detection is enabled on your Exotel account; the field itself is inert
SIP trunkNot available. Native SIP has no AMD — the call proceeds as if a person answered

On Exotel and SIP the platform logs a warning when a call requests detection, so screening and voicemail settings that look "randomly dead" on those carriers have a trace explaining why.

Campaigns take the same values once for the whole run — see machineDetection on campaigns.

Choosing the caller ID

By default a call goes out from the number allocated to the agent on the Numbers page. To present a different one, just send the number:

curl -X POST https://api.telenow.ai/api/sessions/initiate-call \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "…",
    "mobileNumber": "+14155550123",
    "fromNumber": "+14155550123"
  }'

fromNumber is matched against your own numbers on digits alone, so +1 415 555 0123, +14155550123 and 14155550123 all work. If you'd rather use ids — they're stable across re-formatting — GET /api/v1/numbers returns id and phone_number for every number you own, and fromNumberId takes the id. Send one or the other; sending both is a 400 rather than a silent pick.

Resolution order:

  1. fromNumber / fromNumberId, when you send one.
  2. The agent's default caller ID (Numbers → the number's Outgoing calls card).
  3. The number allocated to that agent for inbound.

Rules worth knowing:

  • Both forms are scoped to your organization. fromNumber is not a passthrough — the string never reaches the carrier. It is looked up among your own live numbers, so sending someone else's number is the same 400 as sending a foreign id; there is no way to spoof a caller ID.
  • An id you don't own is a 400, not a silent fallback — dialing from a different number than the one you asked for is worse than failing.
  • The number decides the carrier. Picking a number issued by a different provider than the agent's configured one routes the call through that number's carrier, using its credentials.
  • Concurrency follows the number you pick, not the agent's default — the per‑number cap applies to whichever number is presenting.
  • Numbers that only ever dial out don't need to be assigned to an agent at all. Leave them unallocated and set their inbound behaviour on the Numbers page.

Queue calls instead of dialing now

/initiate-call places one call and refuses the overflow with 429 once your concurrency cap is full — so firing a large list at it means handling a burst of retries yourself. Pass queue: true to hand the number to a managed queue instead: it is parked and dialed by a background worker at your organization's concurrency, with automatic retries, DNC suppression, and de‑duplication. The request returns immediately with 202 Accepted — there is no live session yet.

curl -X POST https://api.telenow.ai/api/sessions/initiate-call \
  -H "X-API-Key: $TELENOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "…",
    "mobileNumber": "+14155550123",
    "queue": true,
    "maxAttempts": 3,
    "retryBackoffSecs": 300,
    "retryOnNoAnswer": true
  }'
FieldTypeNotes
queuebooleanSet true to queue the call rather than dial now. Omit (or false) for the standard synchronous behaviour above
maxAttemptsintegerTotal dial attempts per number incl. the first (1–10, default 3)
retryBackoffSecsintegerBase backoff between attempts; grows exponentially, capped at 1h (5–3600, default 300)
retryOnNoAnswerbooleanRedial an unanswered/busy call (default true). Hard failures always retry until maxAttempts

Queued response:

{
  "success": true,
  "queued": true,
  "campaignId": "…",
  "deduplicated": false,
  "pendingAhead": 12
}
  • deduplicated: true means that number was already live in the queue, so nothing new was added — a number is held unique while pending/dialing (a finished number can be re‑queued later).
  • pendingAhead is the queue depth after this call.
  • Fire the same list twice, or 200 numbers in parallel: they all land in one queue for the agent, deduped, and drain at your cap. No 429 to handle.

Good to know:

  • Retry / AMD settings are honored per call. Calls that share the same caller ID and the same retry/machineDetection policy share one queue; a different policy gets its own.
  • The response differs from the synchronous dial (202 + queued, no sessionId) — gated strictly on queue: true, so callers that don't set the flag are unaffected.
  • A per‑call firstResponse is honored, same as on the synchronous path — the opener is stored per number and replayed on every attempt, retries included. variables likewise. Two enqueues of a number already live in the queue dedupe to one, and the first opener wins.
  • The caller ID must resolve to one of your platform numbers (a raw external fromNumber with no match is a 400). +91 destinations obey the 09:00–21:00 IST calling window even when queued.

List queued calls

GET /api/sessions/queue?agentId={id}&status={status}&limit={n}&offset={n}

Returns the queued / in‑flight calls for an agent, newest first, paginated. status filters to one of pending · dialing · completed · failed · skipped; omit for all (the live queue is pending + dialing). Also surfaced in the dashboard under the agent's Queue tab.

{
  "success": true,
  "data": {
    "items": [
      {
        "id": "…",
        "phone_number": "+14155550123",
        "status": "pending",
        "attempt": 0,
        "scheduled_for": "2026-08-08T12:00:00Z",
        "last_error": null,
        "session_id": null
      }
    ],
    "total": 12
  }
}

Session lifecycle

MethodPathPurpose
POST/api/sessions/init-web-callStart a browser session (agent mode) or bridge a softphone leg (mode: "manual") — see Web‑call API
GET/api/sessions/{id}Session status (message count, current turn)
GET/api/sessions/{id}/messagesLive transcript (messages)
GET/api/sessions/{id}/tool-invocationsTool calls made during the session
POST/api/sessions/{id}/transferWarm‑transfer the call (see below)
POST/api/sessions/{id}/playPlay an uploaded recording into the live call (see below)
DELETE/api/sessions/{id}End the session early

Transfer a live call

Warm‑transfer the active call to another number — pass the E.164 number to dial:

curl -X POST https://api.telenow.ai/api/sessions/{id}/transfer \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15551234567" }'

Telenow bridges the live call to that number (Do‑Not‑Call checked). The session must still be active (else 400). Works on phone calls on every carrier (Plivo, Twilio, Vobiz, Exotel, Vonage, SIP trunks) and on web (browser) calls — a web call dials the human over the phone network using the agent's bound number as caller ID, then bridges the browser audio to that call, so the agent needs a phone number assigned on the Numbers page. This is how a Custom API workflow escalates to a human. Authenticate with an API key or a user JWT + X-Org-Id. Tata Tele Smartflo is the exception: a transfer on a live phone call is refused, because Tata offers no way to redirect or bridge a call already on a voice-streaming leg. A transfer from a web call whose agent is bound to a Smartflo number does work — there Telenow places a fresh outbound call to the human rather than redirecting an existing one.

Play a recording into a live call

Play a track from your organization's audio library into the call. Upload once (any WAV or MP3 — Telenow normalises it to the call format), then play it by id:

curl -X POST https://api.telenow.ai/api/sessions/{id}/play \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "trackId": "3f4a1b2c-…" }'
{ "success": true, "data": { "sessionId": "…", "trackId": "…", "durationMs": 8400 } }

Returns as soon as playback is queued, with the track's duration — a five‑minute recording would otherwise hold the request open for five minutes. Calling it again supersedes whatever is currently playing. The session must still be active (else 400).

Works on an agent call and on a manual (softphone) call alike. That combination — init-web-call with mode: "manual", this endpoint, and the call.dtmf webhook — is a fully programmable call: dial from your server, play your own recording, and receive each keypress on your URL, with no browser leg and no AI in the loop.

POST /api/sessions/init-web-call   { "mode": "manual", "fromNumber": "+91…", "toNumber": "+91…" }
POST /api/sessions/{id}/play       { "trackId": "…" }          ← your recording
        ↓  caller presses 1
POST https://your-server/webhook   { "event": "call.dtmf", "digit": "1", … }
DELETE /api/sessions/{id}                                       ← hang up

Note the browser leg is optional but the call is not silent without it only because you are playing audio into it — a manual call with nothing played and no browser attached is dead air on the callee's side.

Call history

Finished calls live under the organization. The list endpoint is paginated and filterable.

GET /api/orgs/{orgId}/calls
Query paramPurpose
agentIdFilter to one agent
callModeagent or manual (softphone)
statusFilter by raw session status (active, ended, …)
from, toInclusive date range (YYYY-MM-DD) on the start time
callerFilter by caller digits (matched against from_number)
minetrue → only the caller's own calls
sortnewest (default) · oldest · longest · shortest
limit, offsetPagination (default 50 / 0; limit clamped 1–200)
{
  "success": true,
  "data": { "calls": [ /* … */ ], "total": 842 }
}

Each call object (same shape on the list and the detail endpoint, all snake_case):

FieldNotes
id, org_id, agent_id, agent_name, user_idIdentity
statusRaw session status (active / ended / failed)
call_modeagent or manual (softphone)
channelDerived: softphone · telephony · web
directionDerived: web for web calls, otherwise outbound
from_number, to_number, phone_numberto_number falls back to phone_number for agent telephony
call_sidCarrier call id
start_time, end_time, duration_secTiming
dispositionCarrier terminal disposition: answered / no-answer / busy / failed / null
answered_byAMD verdict: human / machine_* / null
wrapup_dispositionOperator wrap‑up outcome (softphone), independent of disposition
initiated_by, initiated_by_email, initiated_by_api_keyRaw initiator references
initiated_by_nameFriendly label: the user's name/email, or the API key's name; null for inbound
initiated_via"user" · "api" · null — lets the UI badge the source
resp_ms_avg, resp_ms_max, resp_samples, audio_gap_countCall‑quality telemetry (null = not measured)
stt_ms_avg, llm_ms_avg, tts_ms_avg, net_rtt_ms_avgLatency breakdown (net_rtt_ms_avg is web‑only)
recording_idLatest recording (back‑compat)
caller_memory{ used, chars } or null
lead, source, variablesWidget lead answers, provenance, resolved context variables

The detail endpoint adds transcript[], recordings[] (all parts — see Recordings API), stack[] (the provider/model per component as metered on this call), and aggregated llm_prompt_tokens / llm_completion_tokens / llm_total_tokens / llm_cached_tokens.

CSV export

GET /api/orgs/{orgId}/calls/export

Returns the filtered call history as a CSV attachment. Same filters as the list (no pagination); capped at 50,000 rows. Columns: id, started_at, ended_at, duration_sec, status, call_mode, agent, from_number, to_number, disposition, wrapup, answered_by, initiated_by, avg_response_ms.

If the file is incomplete, the response says so rather than just stopping: X-Export-Truncated: true, plus X-Export-Rows (what you got) and X-Export-Total (what matched), and the filename gains -partial-<rows>-of-<total>. X-Export-Truncated is sent on every export, so false is a positive "this file is whole" rather than silence. The dashboard shows a notice too. See the campaigns export for the full description.

Call detail, cost, insights, notes & follow‑ups

MethodPathPurpose
GET/api/orgs/{orgId}/calls/{id}Call detail + transcript + recordings + stack
GET/api/orgs/{orgId}/calls/{id}/costSettled per‑call cost breakdown (data: null until rated, ~2–3 min)
GET/api/orgs/{orgId}/calls/{id}/insightsCached AI insights (404 until generated)
POST/api/orgs/{orgId}/calls/{id}/insightsGenerate insights (summary / sentiment / talk‑ratio)
GET/POST/api/orgs/{orgId}/calls/{id}/commentsList / add notes
GET/POST/api/orgs/{orgId}/calls/{id}/followupsList / create follow‑ups
PATCH/api/orgs/{orgId}/calls/{id}/followups/{fid}Update a follow‑up (status = open / done) — only the author may edit

Wrap‑up

Record a softphone operator's post‑call outcome:

POST /api/orgs/{orgId}/calls/{id}/wrapup
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/calls/{id}/wrapup \
  -H "X-API-Key: vai_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "disposition": "callback", "note": "Wants a quote by Friday" }'
FieldTypeNotes
dispositionstringRequired, max 64 chars. The softphone offers presets (interested, not‑interested, callback, voicemail, wrong‑number, other) but any short code is accepted
notestringOptional. Stored as a regular call comment, so it appears everywhere comments do

Member‑gated and org‑scoped; last write wins (re‑wrapping corrects a slip). Sets wrapup_disposition on the call.

Live listen‑in

Supervisors can monitor a live call. This is privacy‑sensitive, so it's restricted to org owners/admins:

POST /api/orgs/{orgId}/calls/{id}/listen
{ "success": true, "data": { "ticket": "…", "wsUrl": "wss://…/ws/monitor?ticket=…" } }

The endpoint mints a single‑use, short‑lived (30‑second) ticket and returns the WebSocket URL to open. The /ws/monitor socket exchanges the ticket for the session at connect time, then streams both legs' mixed audio to the supervisor's browser — read‑only (the supervisor can't be heard). The call must be live on this node and still active, else 409; a missing/cross‑org call returns 404.

To be notified when a call ends (rather than polling), subscribe to the call.ended webhook.

Three call endpoints not listed above

MethodPathPurposeRole
GET/api/orgs/{orgId}/calls/columnsEvery column the call list and export can returnmember
GET/api/orgs/{orgId}/calls/{id}/traceFull start→end event trace for one callowner/admin
POST/api/orgs/{orgId}/calls/{id}/stream-ticketShort-lived ticket to listen to a call livemember

/columns exists so you never hard-code a column list. It also reports what the export writes when you ask for nothing, which is a compatibility contract — a picker that offers a column ?fields= would reject is exactly the failure this endpoint removes. Read it at runtime rather than shipping a copy.

/trace is 404 or empty unless the call was actually traced. Tracing needs sessionConfig.debugTrace left on for the agent and DEBUG_TRACE_ENABLED on the deployment. It is owner/admin only because a trace is PII-heavy — it is the whole call, not a summary.

/stream-ticket is for live listening. It returns a short-lived ticket plus the WebSocket base to use; on a multi-pod deployment that base may point at a peer pod, so use the one you are given rather than assuming the host you called.