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}/messagesandGET /api/sessions/{id}/tool-invocationsare user‑JWT only — an API key gets401. To read a transcript programmatically useGET /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"
}'
| Field | Type | Notes |
|---|---|---|
agentId | UUID | Required. Agent that runs the call |
mobileNumber | string | Required. Destination in E.164 |
userId | UUID | Optional attribution |
callType | string | Optional. "whatsapp" / "whatsapp_voice" route a Plivo WhatsApp voice call |
firstResponse | string | Optional. 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 |
machineDetection | string | "true" (leave a voicemail) or "hangup" (drop on a machine). See Answering‑machine detection for the carrier matrix and what omitting it does |
variables | object | Optional { "name": "value" } map for the agent's context variables. Required variables that are missing/blank return 400 |
identifier | string | Optional trusted unique id for the caller (e.g. account id). Injected into tool calls when the agent has caller identity enabled |
fromNumber | string | Optional 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 |
fromNumberId | UUID | The 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):
| Condition | Result |
|---|---|
| Destination on the Do‑Not‑Call list | 403 |
| Monthly quota reached | 403 |
| Concurrency limit reached — per number, workspace or browser | 429 with Retry-After; body carries scope, cap, active, position, retryAfter |
| Agent's number already on a call | 409, reason: "agent_busy" |
| Carrier circuit breaker open (repeated failures) | 502, reason: "carrier_circuit_open", retryAfterSecs |
| Carrier refused the dial for a reason on YOUR account | 400 with a stable code — see below |
| Carrier dial rejected for anything else, or the carrier is unwell | 502 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).
code | Meaning |
|---|---|
exotel_no_credit | Your Exotel prepaid wallet is empty. Top up at my.exotel.com. |
exotel_platform_no_credit | The number dials on Telenow's own carrier account, which cannot place calls. Contact support. |
exotel_kyc_incomplete | Exotel KYC is not complete. Topping up will not clear it. |
exotel_app_id_missing | The Exotel connection has no App Bazaar flow ID, so outbound calls cannot be placed (inbound still works). |
exotel_caller_id_invalid | The from-number is not an ExoPhone on that Exotel account. |
exotel_credentials_rejected | Exotel rejected the API credentials on the connection. |
exotel_not_configured | Part of the Exotel credentials is missing. |
exotel_rejected | Exotel refused for a reason we have no specific reading for; error carries its own words. |
smartflo_no_credit | Your Smartflo prepaid wallet is empty. Top up in the Smartflo portal. |
smartflo_platform_no_credit | The number dials on Telenow's own carrier account, which cannot place calls. Contact support. |
smartflo_kyc_incomplete | Smartflo KYC/verification is not complete on the account. Topping up will not clear it. |
smartflo_c2c_key_missing | No 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_streaming | The Click-to-Call key's Destination is not the VOICE Bot, so the call connects to something other than your agent. |
smartflo_token_expired | The 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_rejected | Smartflo rejected the API token on the connection (wrong, revoked, or IP/scope restricted). |
smartflo_caller_id_invalid | The from-number is not a DID valid on that Smartflo account. |
smartflo_not_configured | Part of the Smartflo credentials is missing. |
smartflo_rejected | Smartflo 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" }'
| Value | Behaviour |
|---|---|
"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 |
| omitted | Fall 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:
| Config | On a machine verdict | Ends the call? |
|---|---|---|
ivrHangup: { "enabled": true } | Hang up at once — for dialing lists where an IVR or menu is a dead end | Yes |
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 it | No, the conversation continues |
| neither | Use the per‑call machineDetection value: "hangup" drops, otherwise leave the voicemail | Voicemail 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 ivrHangup → callScreen → 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.
| Carrier | machineDetection |
|---|---|
| Plivo, Vobiz, Vonage | Supported |
| Twilio | Supported. "true" maps to DetectMessageEnd so the drop lands on the recording; "hangup" maps to Enable, which fires as soon as the machine starts |
| Exotel | No per‑call arm. Verdicts arrive only if AnsweredBy detection is enabled on your Exotel account; the field itself is inert |
| SIP trunk | Not 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:
fromNumber/fromNumberId, when you send one.- The agent's default caller ID (Numbers → the number's Outgoing calls card).
- The number allocated to that agent for inbound.
Rules worth knowing:
- Both forms are scoped to your organization.
fromNumberis 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 same400as 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
}'
| Field | Type | Notes |
|---|---|---|
queue | boolean | Set true to queue the call rather than dial now. Omit (or false) for the standard synchronous behaviour above |
maxAttempts | integer | Total dial attempts per number incl. the first (1–10, default 3) |
retryBackoffSecs | integer | Base backoff between attempts; grows exponentially, capped at 1h (5–3600, default 300) |
retryOnNoAnswer | boolean | Redial an unanswered/busy call (default true). Hard failures always retry until maxAttempts |
Queued response:
{
"success": true,
"queued": true,
"campaignId": "…",
"deduplicated": false,
"pendingAhead": 12
}
deduplicated: truemeans 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).pendingAheadis 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
429to handle.
Good to know:
- Retry / AMD settings are honored per call. Calls that share the same caller ID and the
same retry/
machineDetectionpolicy share one queue; a different policy gets its own. - The response differs from the synchronous dial (
202+queued, nosessionId) — gated strictly onqueue: true, so callers that don't set the flag are unaffected. - A per‑call
firstResponseis honored, same as on the synchronous path — the opener is stored per number and replayed on every attempt, retries included.variableslikewise. 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
fromNumberwith no match is a400).+91destinations 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
| Method | Path | Purpose |
|---|---|---|
POST | /api/sessions/init-web-call | Start 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}/messages | Live transcript (messages) |
GET | /api/sessions/{id}/tool-invocations | Tool calls made during the session |
POST | /api/sessions/{id}/transfer | Warm‑transfer the call (see below) |
POST | /api/sessions/{id}/play | Play 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 param | Purpose |
|---|---|
agentId | Filter to one agent |
callMode | agent or manual (softphone) |
status | Filter by raw session status (active, ended, …) |
from, to | Inclusive date range (YYYY-MM-DD) on the start time |
caller | Filter by caller digits (matched against from_number) |
mine | true → only the caller's own calls |
sort | newest (default) · oldest · longest · shortest |
limit, offset | Pagination (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):
| Field | Notes |
|---|---|
id, org_id, agent_id, agent_name, user_id | Identity |
status | Raw session status (active / ended / failed) |
call_mode | agent or manual (softphone) |
channel | Derived: softphone · telephony · web |
direction | Derived: web for web calls, otherwise outbound |
from_number, to_number, phone_number | to_number falls back to phone_number for agent telephony |
call_sid | Carrier call id |
start_time, end_time, duration_sec | Timing |
disposition | Carrier terminal disposition: answered / no-answer / busy / failed / null |
answered_by | AMD verdict: human / machine_* / null |
wrapup_disposition | Operator wrap‑up outcome (softphone), independent of disposition |
initiated_by, initiated_by_email, initiated_by_api_key | Raw initiator references |
initiated_by_name | Friendly 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_count | Call‑quality telemetry (null = not measured) |
stt_ms_avg, llm_ms_avg, tts_ms_avg, net_rtt_ms_avg | Latency breakdown (net_rtt_ms_avg is web‑only) |
recording_id | Latest recording (back‑compat) |
caller_memory | { used, chars } or null |
lead, source, variables | Widget 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
| Method | Path | Purpose |
|---|---|---|
GET | /api/orgs/{orgId}/calls/{id} | Call detail + transcript + recordings + stack |
GET | /api/orgs/{orgId}/calls/{id}/cost | Settled per‑call cost breakdown (data: null until rated, ~2–3 min) |
GET | /api/orgs/{orgId}/calls/{id}/insights | Cached AI insights (404 until generated) |
POST | /api/orgs/{orgId}/calls/{id}/insights | Generate insights (summary / sentiment / talk‑ratio) |
GET/POST | /api/orgs/{orgId}/calls/{id}/comments | List / add notes |
GET/POST | /api/orgs/{orgId}/calls/{id}/followups | List / 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" }'
| Field | Type | Notes |
|---|---|---|
disposition | string | Required, max 64 chars. The softphone offers presets (interested, not‑interested, callback, voicemail, wrong‑number, other) but any short code is accepted |
note | string | Optional. 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
| Method | Path | Purpose | Role |
|---|---|---|---|
GET | /api/orgs/{orgId}/calls/columns | Every column the call list and export can return | member |
GET | /api/orgs/{orgId}/calls/{id}/trace | Full start→end event trace for one call | owner/admin |
POST | /api/orgs/{orgId}/calls/{id}/stream-ticket | Short-lived ticket to listen to a call live | member |
/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.