Calls
The company call journal (GET /calls, GET /calls/counters) and programmatic voice-call control: originate an outbound call to a contact (POST /calls/originate) and hang up an active call (POST /calls/{call}/hangup). This is a call-control API, not a browser softphone — it only hands the call to the AISAR telephony gateway; the audio itself travels over SIP/WebRTC outside HTTP. Also includes the supervisor console (/v1/telephony/*): active calls, team presence, a wallboard, historical stats, and supervisor actions on a live call (listen/whisper/barge/transfer).
Access and requirements
The whole group requires an active subscription and the telephony plan feature; on top of that the calling user must be a provisioned telephony operator (have an active SIP endpoint) and hold the telephony.use permission in the current company — otherwise 403 { "data": { "message": "Telephony is not available for your account." } }.
Originate a call (`POST /calls/originate`)
POST /calls/originate takes contact_id and channel_id (both required integers). The channel picks the dial path: a WhatsApp/WABA channel calls the contact over WhatsApp, while a sip_telephony channel dials the contact's phone number over a SIP trunk. Both the contact and the channel must belong to the current company (otherwise 404 { "data": { "message": "Contact or channel not found." } }).
Response and call statuses
On success (200) it returns the created call record: { "data": { "call": { id, direction: "outbound", status: "ringing", to_number, from_number, asterisk_channel_id, timeline_message_id } } } — the call starts in ringing and a call card is appended to the conversation timeline (timeline_message_id). Subsequent status transitions (ringing → answered → completed, or rejected / missed / failed) arrive via real-time events and call.* webhooks, not in this response body.
Call permission (WhatsApp and SIP)
WhatsApp calls require a valid call permission from the contact: without it originate returns 422 with an eligibility block where call.can_call = false. Check readiness and request permission ahead of time via GET /v1/contacts/{contact}/outbound-eligibility and POST /v1/contacts/{contact}/call-permission/request (the Contacts group). SIP channels need no permission (eligibility.call.permission_status = "not_required"), only a phone number on the contact — otherwise 422 { "data": { "message": "Contact has no phone number to dial." } }.
Other originate outcomes
Other originate outcomes:
403with aneligibilityblock — the channel or contact cannot be called (no WhatsApp account, operator not enabled).502— the telephony gateway failed to originate the call.422— request-body validation error (missingcontact_id/channel_id).- A channel with
sip_outbound_gated: true(see the Channels group) is skipped when picking which SIP channel handles the call — if it is the company's only SIP channel,originatebehaves as if there were no connected SIP channel at all. - For a
sip_telephonychannel connected in IP-address mode only: an additional anti-abuse check — a disallowed destination prefix, or exceeding the 300-outbound-calls-per-hour-per-channel cap — returns422with"This destination cannot be dialled from this line."or"Too many calls placed on this line recently. Try again later.". Channels connected via login/password are not subject to this check.
Hang up a call (`POST /calls/{call}/hangup`)
POST /calls/{call}/hangup ends an active call. Beyond telephony availability, only a party to the call (the assigned operator or a call leg) or a user with company telephony-management rights may hang it up — otherwise 403. Success: { "message": "Hangup requested." } (the teardown request is handed to the gateway; actual termination is confirmed by a call.* event). Note: the hangup response, unlike originate, is not wrapped in data.
Call transcript (`GET /calls/{call}/transcript`)
GET /calls/{call}/transcript returns the transcript and summary of the voice AI session (ElevenLabs) attached to a call. This endpoint has its OWN access gate, separate from the group's general requirement — telephony.use and a provisioned SIP account are not required here. Any ONE of three conditions is enough: (a) the caller is a party to the call (the assigned operator or a call leg, the same check hangup uses); (b) the caller holds company_telephony.manage; (c) the call was answered by an AI agent (assignee_type = "ai_agent") and the caller holds conversation.view on that call's conversation — so any operator reading that conversation can open the transcript, not just a supervisor. For a human-answered call, conversation.view grants no access: only the call's own party or a telephony manager can read it.
The company is checked first, before anything else: a {call} from another company (or a non-existent one) is 404 (reads as "this id doesn't exist"), not 403. If the company matches but none of the three access branches applies — 403. If the call has no voice session at all (an ordinary non-AI call) — also 404: { "data": { "message": "No voice session for this call." } }.
A successful response (200): { "data": { voiceCallId, callId, status, outcome, summary, transcript: [...], startedAt, answeredAt, endedAt } } — note that this response's fields are camelCase, unlike snake_case elsewhere in the group. voiceCallId is the voice session's UUID, callId is the call id (matches {call} in the path). status is the session state (e.g. dialing/in_progress/completed/failed). outcome is resolved/failed/null when the vendor didn't determine an outcome. summary is a text summary of the call, or null. startedAt/answeredAt/endedAt are ISO-8601 or null.
Each transcript[] item is a turn: role (string, user/agent/…), text, at_sec (seconds into the call, a number), and tool_calls[] — the AI agent's tool invocations on that turn. Each tool_calls[] entry carries a fixed set of fields: type, tool_name, request_id, params_as_json (string — the call's arguments as the vendor sent them), tool_has_been_called (bool), result (the tool's result: parsed JSON, a raw string, or null when no result was recorded), is_error (bool or null; null means "no result recorded", NOT success), error (the vendor's error text or null), and latency_sec (seconds as a number, or null). The vendor can report a tool call and its result on different turns — the API already matches them by request_id and returns the result on the same turn the call happened on.
Call recording link (`GET /calls/{call}/recording-url`)
GET /calls/{call}/recording-url mints a one-off signed link to the call's audio recording — the on-demand counterpart to the recording_url field in the journal (GET /calls), minted on an explicit click rather than upfront for every list row. The access gate is identical to transcript (above): a call party, company_telephony.manage, or an AI-call conversation reader via conversation.view; the same 404 (different company) → 403 (no matching branch) order. If the call has no stored recording (recording_status isn't stored, or no matching recording attachment was found) — 404 { "data": { "message": "No recording for this call." } }.
A successful response (200): { "data": { "recording_url": "<signed link>" } } — the link is valid for 15 minutes (shorter than the 30-minute link embedded in the journal list, since it's minted on a direct play/download click rather than in advance). An optional via=play|download query param (default play) — download adds Content-Disposition: attachment, forcing the browser to download the file instead of playing it inline. Rate limit: 30 requests/minute per user.
Call log (`GET /calls`)
GET /calls returns the company call journal wrapped as { "data": { "items": [...], "pagination": {...} } } (this API's list-endpoint convention, NOT the plain { "data": [...] } envelope). By default an operator only sees calls they were party to (assigned, caller, or callee); the company_telephony.manage permission widens visibility to every call in the company — the exact same visibility scope used by GET /calls/counters, so the counters can never drift from the list. Base filters (all optional query params): filter=all|missed (default all; missed = unanswered inbound calls), direction=inbound|outbound, channel_id (integer, must exist), q (string, up to 100 chars — case-insensitive substring search across from_number/to_number and the contact's name), assignee_type=operator|ai_agent, page (≥1), perPage (1–100, default 20). Sorted by started_at descending. Rate limit: 120 requests/minute per user (the telephony-calls-index limiter, added together with the archive filters below — GET /calls had no dedicated throttle before that); exceeding it returns 429.
Recordings archive filters (`GET /calls`, Ф3c)
The same GET /calls accepts an additional set of optional query params for the "Записи" (recordings) archive mode — omitting them leaves the journal's existing behavior (including the embedded chat's use of it) unchanged: has_recording=1 — only calls with a stored recording (recording_status = "stored"); assigned_user_id (integer) — calls assigned to a specific operator (the value isn't existence-checked — an id from another company simply matches nothing rather than 404/422, since visibility is already scoped to the current company); date_from/date_to (YYYY-MM-DD dates, date_to >= date_from) — calendar dates, resolved in the company's timezone and converted to UTC day boundaries, the same way GET /telephony/stats does; with_summary=1 — adds a summary block to the response covering the ENTIRE filtered set (see below), not just the current page; lazy_recordings=1 — skips pre-signing recording_url for every list row (the field is null on all rows, including ones that do have a recording) — the link is minted on demand via GET /calls/{call}/recording-url.
With with_summary=1 the response additionally carries { "data": { ..., "summary": { "count": N, "total_duration_sec": S } } } — the count and total duration in seconds across ALL calls matching the current filter set (not just the calls on the page). The summary block is absent from the response when with_summary isn't passed.
Each items[] element is a call object: id, direction, status (ringing/answered/completed/rejected/missed/failed), provider (whatsapp/sip/internal), from_number, to_number, timestamps started_at/answered_at/ended_at (ISO-8601 or null), duration_sec, nested contact (id, display_name, avatar_url) and assignee (id, name), channel_id/channel_name/channel_type (the channel type slug, ChannelType::key, e.g. whatsapp_business or sip_telephony), conversation_id, timeline_message_id, recording_status, recording_url, has_recording (bool, = recording_status === "stored"), and has_transcript (bool — whether the call has a voice AI session, i.e. whether GET /calls/{call}/transcript would return a dialog instead of 404). recording_url is a temporary signed link (~30 min TTL); present only when recording_status = "stored" AND the request did NOT pass lazy_recordings=1 — with lazy_recordings=1 the field is always null, even for calls that do have a recording (use GET /calls/{call}/recording-url on click instead). Unlike recording_url, has_recording does not depend on lazy_recordings and stays a reliable recording-presence indicator in both modes. pagination is { page, perPage, total, lastPage }.
Call counters (`GET /calls/counters`)
GET /calls/counters returns badge counters { "data": { "all": N, "missed": M } } — built from the exact same visibility query as GET /calls (without the direction/channel_id filters), so the numbers can never drift from the list. No parameters.
Supervisor console: access
Every /v1/telephony/* endpoint below (console, stats, supervisor actions) is gated SEPARATELY from the rest of the group: not by the telephony plan feature + telephony.use (that's the /calls/* gate above), but by the company_telephony.manage permission — without it, 403. The caller does not need a provisioned SIP endpoint (unlike telephony.use). As of publication there is no dedicated telephony.supervise permission: access runs through company_telephony.manage, which the admin role holds by default (product decision, 2026-08-13).
Console: active calls, presence, wallboard
Three parameter-less read-only endpoints, each returning a "right now" snapshot (not paginated). GET /telephony/console/active-calls — { "data": { "items": [...] } }, each item: id, direction, kind, provider, status, contact (id/display_name/avatar_url or null), channel_id, channel_name, from_number, to_number, started_at/answered_at (ISO-8601 or null), waiting_sec (time spent ringing before answer, or time spent ringing so far if still unanswered), talk_sec (ongoing talk time, null until answered), assignee ({ type: "operator"|"ai_agent"|null, id, name }).
GET /telephony/console/presence — { "data": { "items": [...] } }, one item per operator with an active SIP account: user_id, name, availability (the user's raw availability value), status — a derived status: on_call, acw (after-call work, a short wrap-up window), offline, away, free; devices ({ browser: { registered } | null, external: { registered } | null }) and metrics_today ({ answered, missed, avg_talk_sec }, in the company's timezone). GET /telephony/console/wallboard — a flat object { "data": { active_calls, ringing, operators_online, operators_free, missed_today, service_level_today } }; operators_online/operators_free are derived from that same presence, so they can never disagree with it. service_level_today is a ready percentage (0–100), not a fraction.
Stats (`GET /telephony/stats`)
GET /telephony/stats — historical KPIs and chart series for a period. Required query param period=today|7d|30d|custom; for custom, date_from/date_to are additionally required (dates, date_to >= date_from, range capped at 92 days — otherwise 422). Periods are computed in the company's timezone, then converted to UTC. The response is a flat object { "data": { from, to, total, answered, missed, avg_talk_sec, service_level, service_level_threshold_sec, ai_share, by_day, by_hour, by_channel, by_assignee } }: service_level and ai_share are ready percentages (0–100, not fractions); service_level_threshold_sec echoes the configured service-level threshold; by_day is [{ date, answered, missed }]; by_hour is [{ hour, total }]; by_channel is [{ channel_id, channel_name, provider, total }]; by_assignee is [{ type, id, name, answered, missed, avg_talk_sec }].
Supervisor actions (`listen`/`whisper`/`barge`/`transfer`)
Four POST /telephony/console/calls/{call}/{action} endpoints for a live call: listen — silently listen in, whisper — whisper to the operator (customer can't hear), barge — join the call audibly (both sides can hear), transfer — hand the call to another operator. All 4 actions require the call to be in the answered state, otherwise 422 (transfer originally allowed a still-ringing ringing call too, but neither the bridge nor the console UI actually support that, so review aligned it with the other three). transfer requires the body { "target_user_id": <int> } — the target must be a real (non-service-account) member of the same company AND hold an active SIP operator account there, otherwise 422. Success (200): { "data": { "message": "Supervisor action executed.", "action": "<listen|whisper|barge|transfer>" } }; a call.supervised webhook fires asynchronously right after. Rate limit: 30 requests/minute per user (the telephony-supervise limiter); exceeding it returns 429.
404— the call does not exist, or belongs to a different company.403— the caller is a service account (PAT), OR lacks thecompany_telephony.managepermission (checked in that order).422— the call is not active (already ended, or status is notringing/answered), or has not been answered yet (answeredis required for ALL 4 actions, includingtransfer), orasterisk_channel_idis blank/missing, or (transferonly)target_user_idis missing, or the target is not an active SIP operator of this specific company, or the bridge rejected the action for its own reason — the response body carries a fixed message; the bridge's raw error string is never forwarded to it (only logged server-side).409— the call is already being supervised by someone else (the bridge's own guard).410— the call's live bridge channel disappeared between the check and the call (the call ended in that window).502— the bridge (asterisk-bridge) is unreachable or returned an ARI error.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/callsCompany call journal (paginated, filters all/missed/direction/channel_id/q/assignee_type; archive: has_recording/assigned_user_id/date_from-date_to/with_summary/lazy_recordings). | Company call journal (paginated, filters all/missed/direction/channel_id/q/assignee_type; archive: has_recording/assigned_user_id/date_from-date_to/with_summary/lazy_recordings). |
| GET | /v1/calls/countersCall counters `{all, missed}` — same visibility scope as the journal. | Call counters `{all, missed}` — same visibility scope as the journal. |
| POST | /v1/calls/originateOriginate an outbound call to a contact over a WhatsApp or SIP channel. | Originate an outbound call to a contact over a WhatsApp or SIP channel. |
| POST | /v1/calls/{call}/hangupHang up an active call (allowed for a call party or a telephony manager). | Hang up an active call (allowed for a call party or a telephony manager). |
| GET | /v1/calls/{call}/transcriptVoice AI call transcript and summary (for a call party, telephony manager, or AI-call conversation reader). | Voice AI call transcript and summary (for a call party, telephony manager, or AI-call conversation reader). |
| GET | /v1/calls/{call}/recording-urlA 15-minute signed link to the call recording (same gate as transcript). | A 15-minute signed link to the call recording (same gate as transcript). |
| GET | /v1/telephony/console/active-callsSupervisor console: the company's currently active calls. | Supervisor console: the company's currently active calls. |
| GET | /v1/telephony/console/presenceSupervisor console: telephony operator presence/status. | Supervisor console: telephony operator presence/status. |
| GET | /v1/telephony/console/wallboardSupervisor console: right-now summary KPIs (wallboard). | Supervisor console: right-now summary KPIs (wallboard). |
| GET | /v1/telephony/statsHistorical call stats for a period (today/7d/30d/custom) + chart series. | Historical call stats for a period (today/7d/30d/custom) + chart series. |
| POST | /v1/telephony/console/calls/{call}/listenSupervisor: silently listen in on an answered call. | Supervisor: silently listen in on an answered call. |
| POST | /v1/telephony/console/calls/{call}/whisperSupervisor: whisper to the operator on an answered call (customer can't hear). | Supervisor: whisper to the operator on an answered call (customer can't hear). |
| POST | /v1/telephony/console/calls/{call}/bargeSupervisor: barge into an answered call (audible to both parties). | Supervisor: barge into an answered call (audible to both parties). |
| POST | /v1/telephony/console/calls/{call}/transferSupervisor: transfer the call to another operator in the company (target_user_id). | Supervisor: transfer the call to another operator in the company (target_user_id). |
Examples
Originate a WhatsApp call
Request
curl -X POST https://api.aisar.app/v1/calls/originate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": 42,
"channel_id": 7
}'Response
{
"data": {
"call": {
"id": 5001,
"direction": "outbound",
"status": "ringing",
"to_number": "7700000XXXX",
"from_number": "7708550XXXX",
"asterisk_channel_id": "1720787000.4521",
"timeline_message_id": 88214
}
}
}Originate a call over a SIP trunk
Request
curl -X POST https://api.aisar.app/v1/calls/originate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": 42,
"channel_id": 12
}'Response
{
"data": {
"call": {
"id": 5002,
"direction": "outbound",
"status": "ringing",
"to_number": "7700000XXXX",
"from_number": "7727000XXXX",
"asterisk_channel_id": "1720787100.4530",
"timeline_message_id": 88221
}
}
}WhatsApp call without the contact's permission (422)
Request
curl -X POST https://api.aisar.app/v1/calls/originate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": 42,
"channel_id": 7
}'Response
{
"data": {
"message": "No valid call permission or daily attempts exhausted.",
"eligibility": {
"message": {
"window_open": true,
"window_expires_at": 1720800000,
"requires_template": false
},
"call": {
"channel_kind": "whatsapp",
"can_call": false,
"permission_status": "none",
"permission_expires_at": null,
"can_request_permission": true,
"request_cooldown_until": null,
"attempts_remaining": 5,
"has_whatsapp_account": true
}
}
}
}Hang up an active call
Request
curl -X POST https://api.aisar.app/v1/calls/5001/hangup \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"message": "Hangup requested."
}Telephony not available for the account (403)
Request
curl -X POST https://api.aisar.app/v1/calls/originate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": 42,
"channel_id": 7
}'Response
{
"data": {
"message": "Telephony is not available for your account."
}
}Call log
Request
curl "https://api.aisar.app/v1/calls?filter=missed&perPage=20" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"items": [
{
"id": 5001,
"direction": "inbound",
"status": "missed",
"provider": "whatsapp",
"from_number": "7700000XXXX",
"to_number": "7708550XXXX",
"started_at": "2026-07-11T09:12:00+00:00",
"answered_at": null,
"ended_at": "2026-07-11T09:12:24+00:00",
"duration_sec": 0,
"contact": {
"id": 42,
"display_name": "Aigerim K.",
"avatar_url": null
},
"channel_id": 7,
"channel_name": "WhatsApp — Sales",
"channel_type": "whatsapp_business",
"conversation_id": 913,
"timeline_message_id": 88214,
"recording_status": null,
"recording_url": null,
"has_recording": false,
"has_transcript": false,
"assignee": null
}
],
"pagination": {
"page": 1,
"perPage": 20,
"total": 1,
"lastPage": 1
}
}
}Recordings archive: filter + summary + no pre-signing
Request
curl "https://api.aisar.app/v1/calls?has_recording=1&date_from=2026-08-01&date_to=2026-08-19&with_summary=1&lazy_recordings=1&perPage=20" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"items": [
{
"id": 5010,
"direction": "inbound",
"status": "completed",
"provider": "sip",
"from_number": "7727000XXXX",
"to_number": "7008550XXXX",
"started_at": "2026-08-19T09:14:00+00:00",
"answered_at": "2026-08-19T09:14:01+00:00",
"ended_at": "2026-08-19T09:14:52+00:00",
"duration_sec": 51,
"contact": {
"id": 118,
"display_name": "Nurlan T.",
"avatar_url": null
},
"channel_id": 12,
"channel_name": "SIP — Support",
"channel_type": "sip_telephony",
"conversation_id": 921,
"timeline_message_id": 88250,
"recording_status": "stored",
"recording_url": null,
"has_recording": true,
"has_transcript": true,
"assignee": {
"id": 9,
"name": "Данияр О."
}
}
],
"pagination": {
"page": 1,
"perPage": 20,
"total": 1,
"lastPage": 1
},
"summary": {
"count": 1,
"total_duration_sec": 51
}
}
}Call counters
Request
curl https://api.aisar.app/v1/calls/counters \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"all": 128,
"missed": 6
}
}Call transcript (AI escalated to a human)
Request
curl https://api.aisar.app/v1/calls/5010/transcript \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"voiceCallId": "01927e6a-2b3f-7c91-8e0a-4f6b8b1d9a02",
"callId": 5010,
"status": "completed",
"outcome": "resolved",
"summary": "Клиент попросил соединить с оператором для уточнения деталей заказа.",
"transcript": [
{
"role": "user",
"text": "Переключите на человека",
"at_sec": 40,
"tool_calls": []
},
{
"role": "agent",
"text": "",
"at_sec": 47,
"tool_calls": [
{
"type": "webhook",
"tool_name": "voice_transfer_to_human",
"request_id": "toolu_8f2a1c9d4e",
"params_as_json": "{\"reason\":\"client asked\"}",
"tool_has_been_called": true,
"result": { "ok": true, "message": "Escalated." },
"is_error": false,
"error": null,
"latency_sec": 0.979
}
]
},
{
"role": "agent",
"text": "Соединяю со специалистом.",
"at_sec": 49,
"tool_calls": []
}
],
"startedAt": "2026-08-19T09:14:00+00:00",
"answeredAt": "2026-08-19T09:14:01+00:00",
"endedAt": "2026-08-19T09:14:52+00:00"
}
}The call has no voice session (404)
Request
curl https://api.aisar.app/v1/calls/5002/transcript \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"message": "No voice session for this call."
}
}Call recording link
Request
curl "https://api.aisar.app/v1/calls/5010/recording-url?via=download" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"recording_url": "https://api.aisar.app/v1/messages/88250/attachments/4021/media?disposition=attachment&expires=1755651600&signature=8f2a1c9d4e6b7f0a3c5d8e1f2a4b6c9d0e3f5a7b8c1d2e4f6a9b0c3d5e7f8a1"
}
}The call has no recording (404)
Request
curl https://api.aisar.app/v1/calls/5002/recording-url \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"data": {
"message": "No recording for this call."
}
}