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, a callback queue for missed calls, and supervisor actions on a live call (listen/whisper/barge/transfer). And a slice of the call-routing scheme editor: the scheme list with a direction filter, the operator's dial-out line preview (GET /telephony/outbound-line), and assigning operators/teams to an outbound scheme. Plus the full scheme lifecycle: create, edit, the node graph (builder), binding channels to an inbound scheme, and the audio-prompt library.
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." } }.
Since 2026-09-08 the calls journal and its counters exclude the calls of contacts hidden from the working inbox (POST /v1/contacts/{contact}/hide-from-inbox) — supervisors holding company_telephony.manage included; a missed-call alert is not sent for such a contact either. Since 2026-09-29 the same rule applies by direct id: GET /calls/{call}/recording-url (via=download included) and GET /calls/{call}/transcript (transcript and summary) answer 404 for a call of a hidden contact — just like a call of another company, before the call-party and company_telephony.manage checks, so the answer is the same for every user. Recording links already handed out for such a call stop playing too (404), and the recording is left out of the conversation and contact attachment galleries. Nothing is deleted: once the contact is back in the inbox, the recording and transcript are available again.
Since 2026-09-26 an explicitly picked line (channel_id) that channel access rules (channel_access) close to the operator is refused like a missing one: POST /calls/originate — 404 "Contact or channel not found.", POST /softphone/external-call, POST /contacts/{contact}/call-permission/request and GET /contacts/{contact}/outbound-eligibility — 404 "Channel not found.". Automatic line selection is still done by the server.
Originate a call (`POST /calls/originate`)
POST /calls/originate takes contact_id (required integer) and channel_id (optional since 2026-09-14 — outbound call-routing schemes, see the Telephony group below). 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. When channel_id is given it is used when the operator's scheme and the company policy allow it (details and refusal codes are in the "Choosing the number: three steps" section below) — both the contact and the channel must belong to the current company, otherwise 404 { "data": { "message": "Contact or channel not found." } }. When channel_id is omitted, the line is chosen by the same resolver that answers GET /telephony/outbound-line: if the calling operator has an outbound route, the call leaves on it; with no route, the call behaves exactly as it did before this feature. A route that forbids the call, or finds no available line, returns 422 { "data": { "message": "...", "block_reason": string|null } } (the former) or 422 { "data": { "message": "No line available for this call." } } (the latter). The optional device parameter (since 2026-09-22: browser | native) picks which of the operator's devices takes the operator leg: browser — the browser softphone / embeddable iframe (the only behaviour before that date), native — the operator's native SIP profile (agent-{id}: a partner's SIP app, Zoiper, a desk phone). Without device the leg goes to the browser when the operator has a registered tab, otherwise to the native profile if THAT is registered. The operator leg's INVITE carries the X-AISAR-Call-Id (= call.id from the response) and X-AISAR-Leg: outbound headers, which let a SIP client tell the echo of its own call from an inbound one.
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).422 { "data": { "message": "No registered native SIP device for this operator." } }—device: "native"was requested but the operator's native device is not registered; the customer is not dialed at all.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, and since 2026-09-25 that conversation must also be visible to them under channel access rules (team channel_access; otherwise — 403) — 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, timezone, durationSec } } — 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. timezone is the company's IANA timezone — the one those timestamps should be displayed in. durationSec is the talk duration in seconds, or null; it is taken from the CALL itself, not from the voice session's billable seconds, so it matches the duration_sec of the same row in GET /calls.
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. The channel_id filter (integer) is not checked for existence: a foreign or non-existent id returns 200 with an empty data.items, a non-numeric value still returns 422.
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 }.
Stats drill-down filters (`GET /calls`)
One more set of optional GET /calls query params — the drill-downs from the stats charts and from the missed-call alert. All of them are additive: a request that sends none of them behaves exactly as before.
status— the raw call status:ringing,answered,completed,rejected,missed,failed,answered_elsewhere. It is stricter thanfilter=missed(which is additionally inbound-only); if both are sent, both apply.hour_from/hour_to(integers 0–23, inclusive on both ends) — the hour the call STARTED, in the company's local timezone: the same one the statsby_hourseries is bucketed by, so clicking a bar of the chart lists exactly the calls behind it. Either one may be sent alone.hour_to < hour_fromis a legal range that wraps past midnight:hour_from=18&hour_to=2is the night shift.unassigned=1— calls with neither an assigned operator nor an AI agent. A dedicated boolean rather thanassigned_user_id=null: over HTTP the string "null" is indistinguishable from a caller who meant that literal word.callback_state=pending|resolved— missed inbound calls still open, or already closed, in the callback queue. The predicate is the same oneGET /telephony/callbacksuses (inbound + missed + state), so the journal and the queue can never drift apart.has_transcript=1— only calls that have something to read (a stored transcript). Note it is stricter than thehas_transcriptfield of a row: that field istrueas soon as a voice session exists, even if its transcript never arrived.assignee_type=operator|ai_agent— who took the call; the param already existed, and is now also the drill-down from aby_assigneerow.
Transcript search (`GET /calls?q_in=`)
The q_in param decides where q is searched: contact (the default — phone numbers and the contact's name, the pre-existing behaviour), transcript (the text of voice-call transcripts) or all (both). The transcript search is full-text rather than substring: whole words, no stemming; with all the two match sets are unioned by call id. Every other filter on the request (period, channel, operator, hours) applies to the search as well — it always searches inside the already-filtered journal.
The transcript branch is a separate, more expensive one, with limits of its own:
- With
q_in=transcript|all,qis required and may not be blank — otherwise422. A blank transcript search would not be a search but an export of every conversation in the company. - Its own budget: 20 requests per minute per user (the plain journal stays at 120/min); exceeding it returns
429. - Its own time budget: a search that does not finish within roughly 5 seconds is cancelled and comes back as
504 { "data": { "message": "Transcript search took too long — try a longer or more specific query." } }. The fix is a narrower period or a more specific term. - Embed tokens are refused this branch:
q_in=transcript|allwith such a token returns403. The journal itself, the recordings archive and the default search (q_in=contact) stay available to the embedded chat exactly as before. - Each search branch contributes at most 500 matches; when a branch fills that cap, the response says so via
meta.search_truncated.
With q_in=transcript|all the response carries an extra meta block — { "search_truncated": bool, "search_cap": 500 } (the plain journal has no meta). Rows of items[] that matched inside a transcript gain transcript_matches[] — up to three short excerpts: at_sec (the second of the call to seek the player to, a number), role (who is speaking), text (plain text, no HTML markup; a long turn is trimmed) and highlights — [{ start, end }], the offsets of the matched words inside text in CHARACTERS (not bytes), end exclusive. Highlighting is the client's job: the API deliberately does not return marked-up HTML, because these excerpts are whatever the customer said and they travel to a mobile app, to CRM iframes and to webhooks. Rows with no match simply have no transcript_matches key.
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, callback queue, 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, avatar_url (the operator's photo: an externally hosted picture as-is, an uploaded one as a signed link, or null when there is no photo), 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, service_level_threshold_sec, lines } }; 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, and service_level_threshold_sec echoes the configured service-level threshold (the same one GET /telephony/stats returns) so you can label the KPI ("within N sec") without duplicating the value.
wallboard.lines — the state of the company's phone lines, i.e. the channels that can place and take calls right now (the same predicate as calling_enabled in the Channels group; archived channels are excluded). An array with one item per line: channel_id, name, provider_key (sip_telephony, whatsapp_business, whatsapp), number (config.calling_did, falling back to the channel's account name), state (up — the line works, connecting — coming up, down — unavailable, unknown — state not known), degraded (the line counts as working but its signal is worse than normal — e.g. a SIP trunk in IP mode that never answers OPTIONS), registration_status (the SIP trunk's raw registration status, or null on non-SIP lines), checked_at, last_call_at (ISO-8601 or null), and last_call_answered_by ({ id, name } — the operator who answered the line's most recent call; null when that call was missed, is still ringing, or was answered by an AI agent). A company with no lines gets an empty array.
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, timezone, total, answered, missed, missed_not_called_back, avg_talk_sec, service_level, service_level_threshold_sec, ai_share, directions, waiting_buckets, by_day, by_hour, by_channel, by_assignee } }: timezone is the company's IANA timezone, the one the period bounds and the by_day/by_hour series are computed in (label dates and hours in it, not in the browser's timezone); service_level and ai_share are ready percentages (0–100, not fractions); service_level_threshold_sec echoes the configured service-level threshold; missed_not_called_back (integer) is how many of the period's misses are still open in the callback queue (the same predicate GET /telephony/callbacks uses for state=pending), so it is never greater than missed; directions is { inbound, outbound }, the period's split by direction (every call of that direction counts, answered or not, so inbound + outbound === total); waiting_buckets is the callers' waiting-time distribution (see below); by_day is [{ date, answered, missed }]; by_hour is [{ hour, total, answered, missed }]; by_channel is [{ channel_id, channel_name, provider, total }]; by_assignee is [{ type, id, name, answered, missed, avg_talk_sec }]. Optional channel_ids[] (array of integers, up to 50) limits every figure to the listed company numbers; without it all numbers are counted. A foreign or non-existent id yields zeroes (200), not an error.
About by_hour: the series always covers all 24 hours (missing hours are zero-filled) and the hour is the company's local hour. total counts calls in BOTH directions, while answered/missed count inbound only: an outbound call nobody picked up is not a service failure. Two consequences you must render around: answered + missed is NOT total (in an hour with outbound traffic, or with an inbound call that is still ringing or failed, the two add up to less) — stack answered/missed and label the bar with total. And sum(by_hour[].answered) does not reconcile with by_day[].answered (the daily series and the answered KPI count every direction) — do not try to reconcile the hourly series against the daily one.
About waiting_buckets — { median_sec, buckets }, how long callers waited to be answered. Computed over ANSWERED INBOUND calls only, wait = answered_at − started_at. A missed call's wait measures the caller's patience, not our speed to answer, so the two are never mixed: losses are reported separately (missed, by_hour[].missed, the "nobody picked up" row of by_assignee), and abandoned calls carry their own waited_sec in the callback queue. buckets is always exactly 4 entries of { from_sec, to_sec, count }, even for an empty period (then every count: 0 and median_sec: null); the last one has to_sec: null (open-ended). The edges are bound to the service-level threshold SL (service_level_threshold_sec): 0–10, 10–SL, SL–2×SL, 2×SL and above. With the default SL (20 seconds) that is 0–10 / 10–20 / 20–40 / 40+; with a tighter threshold the first edge follows it down (half of SL, capped at 10 seconds) so the four ranges stay strictly increasing. median_sec is the median wait in seconds, or null when the period had no answered inbound calls. The buckets' count values always add up to the number of answered inbound calls in the period.
The last row of by_assignee is always the "nobody picked up" one: { "type": null, "id": null, "name": null, "answered": 0, "missed": N, "avg_talk_sec": null } — the period's inbound misses attributed to no operator and no AI agent (including calls that were never offered to a ring group whose operators got rows of their own). The row is always present, even when missed: 0 — do not synthesize it client-side and do not treat it as an anomaly. Its answered is structurally 0 (an answered call always gets an assignee) and avg_talk_sec is null rather than 0: nobody talked, which is not the same as "talked for zero seconds". One caveat about sums: a call that rang two operators who both have their own rows counts as missed against each of them, so the by_assignee missed total can exceed the missed KPI by that overlap.
Callback queue (`/telephony/callbacks`)
GET /telephony/callbacks (since 2026-09-10) lists missed inbound calls as "who to call back" tasks. The queue is not a table of its own but is derived from the call journal, so its visibility matches GET /calls: contacts hidden from the working inbox appear in neither. Rows are grouped by the other party (by contact_id first; for a call that never resolved to a contact, by an exact match on the whole number — anonymous calls never merge at all), so three missed calls from the same person are one row with attempts: 3, not three tasks.
Parameters: period=today|7d|30d (default 7d — a Friday-evening miss has to survive the weekend), state=pending|resolved|all (default pending), page (1–1000) and perPage (1–100, default 50); values outside those ranges return 422. The response is { "data": { items, pagination, counters, timezone } }, where pagination is { page, perPage, total, lastPage }, counters is { pending, resolved_today } for the badge, and timezone is the company timezone the periods and resolved_today are computed in. An items[] element: call_id, contact ({ id, display_name, avatar_url } or null), from_number, channel_id, channel_name, conversation_id, missed_at, waited_sec (how long the caller held on before the ring gave up — deliberately a different quantity from an answered call's speed-to-answer, so the two are never averaged together), attempts (at least 1), outside_working_hours (the call arrived outside the company's work schedule), and resolution — null while the task is open, otherwise { type, at, by_user, call_id, duration_sec }.
POST /telephony/callbacks/{call}/resolve takes a task off the queue by hand: body { "resolution": "called_back" | "dismissed" } (required; any other value returns 422). Success (200): { "data": { "call_id": N, "resolution": { ... } } }. Closing the queue by hand is usually unnecessary: once an outbound call to the same party is ANSWERED, every open missed call from that party closes automatically, within a 30-day look-back window; a redial that never connected leaves the queue untouched. An automatic close carries the same resolution.type: "called_back" as a manual one, but with resolution.call_id and resolution.duration_sec filled in — that is the outbound call which closed the task (on a manual close both are null). resolve is limited to 60 requests per minute per user; exceeding it returns 429.
403— the caller lacks thecompany_telephony.managepermission (the same permission the rest of the console uses), or the request carries an embed token: both queue endpoints refuse embedded-widget tokens outright, because the queue is a list of contact names and phone numbers.404(resolveonly) — the call does not exist, belongs to another company, or is not visible to the caller. The permission check runs BEFORE the lookup, so the status code can never be used to probe which call ids exist.409(resolveonly) — the call is not a missed inbound one, or the task is already closed (including automatically, or by another operator); a repeat call neither reopens nor overwrites the existing resolution.
Live updates: every change to the queue (a call finalized as missed, a task closed by hand or automatically) broadcasts the WebSocket event telephony.callback.updated on the company channel with the payload { callId, pending } — enough to repaint the badge and invalidate the list cache; the list itself only ever comes from REST.
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.
Outbound call schemes (wave 1)
GET /telephony/schemes returns the company's call-routing schemes and accepts an optional direction=inbound|outbound filter (absent = every scheme). A list item: id, direction (inbound|outbound), name, comment, status (draft|active), timezone, outbound_priority (integer, default 100 — the tie-break between TEAMS pointing at different outbound schemes: the lower value wins; meaningless on an inbound scheme), channels_count, assigned_operators_count, assigned_teams_count (how many operators/teams are bound to the scheme — an outbound scheme's entry point; always 0 for an inbound one), outbound_line (present only on an outbound scheme, and only when its first dial_external node actually names a line: { "channel_id", "name", "number" }, otherwise null), node_count, updated_at. Requires the company_telephony.manage permission, rate-limited by telephony-schemes (120 requests/minute per user) — the same bucket as the rest of the scheme editor.
Direction is set when the scheme is created (POST /telephony/schemes, body carries direction) and cannot change afterwards — an inbound and an outbound scheme use disjoint node vocabularies. The employee's primary scheme picks the DEFAULT line; whether they can pick a different one by hand is decided by the company's "choose the number before a call" policy (see "Choosing the number: three steps" below). A company with no outbound scheme behaves exactly as it did before this feature: GET /telephony/outbound-line returns "scheme": null and the pre-feature candidate list (the company's first suitable channel).
GET /telephony/outbound-line previews which line the operator's next call will leave on: it uses the same resolver (OutboundLineResolver) the call itself uses, so the dialpad's hint can never disagree with the actual call. Parameters: to_number (phone, ^\+?\d{3,20}$) or contact_id — one of the two is required, otherwise 422. Gated by the telephony.use permission (this is a read about the operator's OWN next call, not an admin action), with its own rate limiter telephony-outbound-line — 60 requests/minute per user (the endpoint is polled on every dialpad keystroke). The full response shape: { "scheme": { "id", "name" } | null, "blocked", "block_reason", "candidates": [ { "channel_id", "name", "dial_mode", "is_default" } ], "rejected": [ { "channel_id", "reason" } ] }. candidates is ordered — the is_default: true line (always first) is what the dialpad preselects; rejected explains why the company's other lines were dropped — channel_not_connected, provider_ip_gated, or a code from the same WhatsApp call-permission vetting as GET /contacts/{contact}/outbound-eligibility (the Contacts group). blocked: true means the scheme itself forbids this call (a hangup branch in the graph) — then block_reason carries the text set when the scheme was authored, and candidates is empty.
The GET /telephony/outbound-line response (both the full and the trimmed embed form) carries allowed_channel_ids — an array of line ids (int[]) the operator may pick manually under their outbound scheme, or null when the employee has no outbound scheme and any line is allowed. The UI narrows its "Call via" list by this field.
A manual line pick obeys the scheme: if POST /calls/originate or POST /softphone/external-call is given a channel_id that is not in the operator's scheme, the answer is 422 { "data": { "message": "This line is not allowed by your outbound route.", "error_code": "line_not_in_scheme" } }. The check runs after the channel-access check (404). Without an outbound scheme there is no restriction.
Choosing the number: three steps
Since 2026-10-20 the number for an outbound call is chosen in three steps. Step 1 is the employee's PRIMARY scheme: the default number comes from its set of lines, and fallbacks drawn inside the scheme itself (a rotation, a "failed, try the next one" chain) work as before. Step 2 is the company's shared number (default_channel_id in /companies/current/telephony/outbound-settings) for employees with no schemes. Step 3 is picking the number by hand before a call (channel_id), which depends on the manual_pick_enabled switch. An employee may belong to several outbound schemes; the admin sets the primary one (PUT /companies/current/members/{memberId}/telephony/outbound-primary, the Teams and users group), and when there is no explicit choice the old order applies: a personal scheme before a team one, then outbound_priority, then creation time. A call NEVER moves to another scheme and never substitutes the shared number on its own: if the primary scheme could not connect, the fallbacks inside it have been tried and the call does not go out.
The manual_pick_enabled switch (default true, i.e. as before the update) applies to ALL surfaces, the integrator API included. When a company turns it off, a channel_id that is not among the lines of the employee's primary scheme is refused: POST /calls/originate and POST /softphone/external-call answer 422 { "data": { "message": "...", "error_code": "manual_pick_disabled" } }. A channel_id that matches a line of the primary scheme goes through, so clients that always send the default number keep working. This is a breaking change for integrators that pass an arbitrary channel_id: before a company turns the switch off, make sure your client either omits channel_id (the server picks the line) or takes it from GET /telephony/outbound-line (lines[].is_default or primary_scheme). Companies that configured nothing are unaffected: the switch is on, there are no schemes, and behaviour is as before. When an employee has no schemes and no shared number is set, the switch restricts nothing; the same goes for a primary scheme with an "any available line" step. When channel_id is omitted and the employee's primary scheme or the shared number is unavailable, the call does not go out: POST /calls/originate answers 422 with error_code: "scheme_blocked" and block_reason: "company_default_unavailable" (the shared number is disabled, deleted or cannot place calls), and the GET /telephony/outbound-line preview returns blocked: true with the same block_reason.
New and extended fields. GET /telephony/outbound-line: manual_pick_enabled (boolean; the only new field in the trimmed embed form too, the UI uses it to decide whether to show the number picker); in the full form only: primary_scheme ({ "id", "name" } or null) and candidates[].source (primary for a line of the primary scheme, company_default for the shared number, legacy for the old "any available line" pick). In the contact-card lines block (contact_id) each line gets source (primary | scheme | company_default | legacy) and scheme_name (the scheme's name or null); scheme_name is never returned to an embed token, source stays. Without contact_id (the dialpad) the response also carries selectable_lines, the lines the employee may pick by hand: [ { "channel_id", "name", "kind", "from_number", "source", "scheme_name" } ], the primary line and the shared number first, then the lines of the employee's other schemes; with manual_pick_enabled off, only the primary scheme's line. An embed softphone token gets this list without from_number and scheme_name, a chat-embed token without scheme_name. GET /telephony/schemes: outbound schemes gain outbound_channel_ids (every line the scheme can dial, int[]) and assignees ({ "operators": [ { "id", "name" } ], "teams": [ { "id", "name" } ] }, up to 5 operators and 5 teams, a preview rather than the full list; GET .../assignments returns the full one); on inbound schemes both fields are null. The endpoints listed above are gated by company_telephony.manage and are closed to embed tokens, except GET /telephony/outbound-line (its trimmed embed form is described above); GET /telephony/schemes now answers 403 to an embed token, because the list carries employee and team names.
GET /companies/current/telephony/outbound-settings and PATCH on the same path are the company policy. Response: { "data": { "default_channel_id": int|null, "default_channel": { "id", "name", "number" } | null, "default_channel_available": boolean|null, "manual_pick_enabled": boolean } }; default_channel_available is null while no shared number is set, and false when one is set but unavailable (then calls of employees with no schemes are refused with company_default_unavailable until the number is replaced). PATCH accepts any subset of default_channel_id (a line id of your own company, or null to clear) and manual_pick_enabled (boolean) and returns the same response. A line that does not belong to the company, was deleted, archived or cannot place calls is refused the same way: 422 { "data": { "error_code": "default_channel_invalid" } }. Gated by company_telephony.manage; PATCH also needs a full-access token (not a scope-restricted one); embed tokens get 403. The limiter is telephony-outbound-settings. Every change writes a telephony.outbound_settings.updated audit record and sends the telephony.outbound.updated WebSocket event (see the real-time events guide).
Each candidate's dial_mode is dial_direct (a SIP trunk or gray/personal WhatsApp: dials whenever the line is up, no permission window, no Meta budget) or dial_window_gated (WhatsApp Business Calling: needs a granted permission window and an unexhausted 24-hour call budget). In wave 1, only dial_direct lines are reachable from the softphone dialpad.
For embed surfaces (the embedded-softphone widget and the partner-panel chat iframe — a token carrying the embed:softphone:* or embed:read ability) the response is HARD trimmed per the 2026-09-13 security review: instead of scheme/candidates/rejected it carries exactly 4 fields — { "blocked", "block_reason", "default_channel_id", "dial_mode" }, where default_channel_id/dial_mode are the same candidate as candidates[0] in the full shape. The company's full line inventory (their DIDs), the scheme name, and why other lines were rejected are telephony inventory a surface embedded inside another origin must not see; the trimming applies regardless of whether the call is real or a preview.
Who calls out through an outbound scheme — GET/PUT /telephony/schemes/{scheme}/assignments, the mirror of binding channels to an inbound scheme. Gated by company_telephony.manage, the same telephony-schemes rate limiter. {scheme} is a uuid not scoped to a tenant by route-model binding, so both methods check scheme.company_id FIRST and answer 404 { "data": { "message": "Scheme not found." } } for a foreign or nonexistent scheme — before any body validation. GET responds { "data": { "assignments": { "operators": [ { "id", "name" } ], "teams": [ { "id", "name" } ], "conflicts": [ { "user_id", "via_team_id", "personal_scheme_id" } ] } } }; soft-deleted operators/teams are filtered out of the list. conflicts is a legacy field that is always an empty array [] since 2026-10-20 (kept for compatibility): an employee may belong to several outbound schemes, and a personal assignment no longer "beats" a team one.
PUT /telephony/schemes/{scheme}/assignments takes the FULL desired set, not a delta: { "operator_ids": [...], "team_ids": [...] } (both required as arrays; an empty array is the legitimate way to say "nobody" — capped at 500 operators and 200 teams). Anything not listed is unassigned. Both operators and teams must actually be members of the company (a role row in model_has_roles for operators, teams.company_id for teams) — otherwise 422 with the foreign ids listed in errors.operator_ids/errors.team_ids. Since 2026-10-20 the request moves NOBODY: an operator or a team may belong to several outbound schemes at once (the old behaviour silently took them off their previous scheme), so binding to another scheme takes nothing away from the others. The moved and conflicts fields stay in the response for compatibility but are always empty: { "data": { "assignments": {...}, "moved": { "operators": [], "teams": [] } } }. If an employee is removed from their primary scheme, the primary pointer is cleared automatically. 422 { "data": { "errors": { "scheme": [...] } } } — trying to assign operators/teams to an inbound scheme (direction ≠ outbound). 409 — two concurrent saves of the same set raced (the unique index caught the collision first); retry the request. Every successful save writes a telephony.outbound_scheme.assigned audit record.
Call schemes: create, build, bind
Everything needed to assemble inbound (and outbound) call routing through the API, without the cabinet. Available only to Business companies (restrict.company.type:business) with an active subscription; every call requires the company_telephony.manage permission in the current company. Both a cookie session and a company Bearer token work, including a partner service-account api_token; embed tokens and connect-session tokens are not allowed. The telephony-schemes limiter (120 requests per minute per user) is shared by the whole editor. A foreign or missing scheme answers 404 { "data": { "message": "Scheme not found." } }. Validation errors come back as 422 { "data": { "message", "errors" } }.
The scheme. POST /telephony/schemes takes name (required, up to 120 characters), direction (inbound by default | outbound; it is NOT changeable after creation, sending direction in a PATCH gives 422, so create a new scheme instead), timezone (an IANA identifier such as Asia/Almaty; UTC by default) and comment (up to 1000 characters). The response is 201 { "data": { "scheme": {...} } }: the scheme is created as a draft with a single entry node (outbound_entry for an outbound one). A draft does not handle calls. GET /telephony/schemes/{scheme} returns the same shape: id, direction, name, comment, timezone, status, outbound_priority, moh_as_ringback, moh_prompt_id, channels[] (id, name), nodes[] (id, type, position {x,y}, data) and edges[] (id, source, target, sourceHandle, label, data).
PATCH /telephony/schemes/{scheme} takes a partial body: name, comment, timezone, status (active turns the scheme on, draft turns it off), outbound_priority (0..1000, meaningful for an outbound scheme only), moh_as_ringback (bool) and moh_prompt_id (the id of a ready audio prompt of your own company, or null). The response is the same { "data": { "scheme": {...} } }. Activating an outbound scheme whose entry leads nowhere is refused: 422, errors.status: ["route_leads_nowhere"]. DELETE /telephony/schemes/{scheme} answers { "data": { "message": "Scheme deleted." } }.
The scheme graph: PATCH /telephony/schemes/{scheme}/builder
PATCH /telephony/schemes/{scheme}/builder REPLACES the scheme graph in full: the old nodes and edges are deleted and the submitted ones are written (not a delta; GET first, edit, and send the whole thing back). Body: nodes[] (required, at least 1; each with id a string up to 255 characters, type, and optional position {x,y} and data object) and edges[] (source, target: the client-side node ids; sourceHandle; optional id, label, data). Client node ids exist only to link the edges inside the request: the id of an existing node of this scheme (a uuid from GET) is kept, all others get fresh uuids from the server (the response returns the final ids, as { "data": { "scheme": {...} } }), and an edge with an unknown source/target is silently dropped. Details on node preservation and the size ceilings are in the next paragraph. Exactly one entry node: entry on an inbound scheme, outbound_entry on an outbound one, otherwise 422 (errors.nodes).
Saving the graph preserves node identifiers: a node whose id matches a node of the same scheme is updated in place rather than recreated, so calls that are right now in a menu, a queue or ringing a group are not dropped when the scheme is edited. A foreign, unknown or non-uuid id creates a new node. Nodes absent from the request body are deleted; edges are always rewritten. Graph size ceilings (exceeding one gives 422): at most 200 nodes and 400 edges; one node data up to 16 KB, one edge data up to 2 KB, the whole graph (nodes and edges) up to 256 KB. data.timeout_sec of ring_group and responsible_manager nodes must be an integer from 5 to 600, otherwise 422 on nodes.N.data.timeout_sec.
Node types depend on the direction; a foreign type gives 422 on nodes.N.type. Inbound: entry, schedule, ring_group, hangup, client_check, play, voicemail, responsible_manager, ivr_menu, queue, ai_agent. Outbound: outbound_entry, schedule, dial_external, hangup. An edge sourceHandle is one of out, work, off, no_answer, known, new, no_input, timeout, digit_0..digit_9, escalated, failed; which of them a node has is listed below. The data keys are not validated by the request schema (a node with unknown keys is stored as is), so stick to the documented ones.
entry: nodata; a singleoutbranch to the first step.play:data.prompt_id(the id of a ready audio prompt); branchout. A missing or not-yet-ready prompt: the node is skipped and the call continues alongout.ivr_menu:data.prompt_id(the menu),data.digits[](accepted digits as strings, for example["1","2"]),data.timeout_sec(how long to wait for a key, 2..30, default 5),data.repeats(how many times to play the menu, 1..5, default 2). Branches:digit_Nfor every accepted digit andno_input(silence, a wrong digit once repeats run out, no ready prompt). An accepted digit with nodigit_Nedge hangs the call up.schedule: eitherdata.intervals[]of{ "weekday": 1..7, "start": "09:00", "end": "18:00" }(weekdayis ISO, 1 = Monday;HH:MMtime in the SCHEME timezone; anendnot later thanstartmeans past midnight, but only the evening part of the interval counts), ordata.use_company_hours: true(the company working hours, the same ones the chat auto-reply uses; a company with no schedule counts as always open). Branchesworkandoff; both must be connected or the call is dropped. Also works in an outbound scheme.ring_group:data.members={ "operators": [user_id], "teams": [team_id] }(empty means the whole company),data.strategy(allrings everyone at once, the default;sequentialone by one;priorityin groups fromdata.priority_groups[], each{ "users": [user_id] }),data.timeout_sec(how long a step rings, 5..600, default 25),data.record(bool, defaulttrue; recording actually happens only when call recording is enabled for the company),data.record_stereo(bool),data.ringback(standard|scheme_moh| a prompt id). Branchno_answer(nobody answered or nobody to ring). If teams or operators are ticked indata.membersbut none of them is left (the team was deleted, is empty or foreign), the node does not ring the whole company but goes straight tono_answer; an empty selection (no teams, no operators) rings the whole company.voicemail:data.greeting_id(the greeting prompt, optional),data.max_sec(message length, 10..600, default 120); branchoutafter recording. Voicemail is not a call recording and is not switched off by the recording setting.hangup:data.reason(a reason string for the journal, defaultscheme_hangup); no branches.- The remaining nodes:
client_check(branchesknown/new: the caller is among the contacts or not),responsible_manager(ring the contact responsible manager; branchno_answer),queue(a waiting queue; branchtimeout),ai_agent(a voice AI employee; branchesescalated/failed; not available to every company); outboundoutbound_entryanddial_external(a line or a carousel of lines:data.channel_id/data.carousel.channel_ids[], up to 20, the lines must belong to your company and be able to place calls). The detailed keys of these nodes are not fixed here.
A dead branch is a route error, not a request error: PATCH .../builder does not check it, but during a call a node without the needed edge (for example a schedule with no off) ends the call. Connect every branch of a node. For an ai_agent node whose agent has escalation enabled, the escalated edge is required already at save time.
Binding channels to an inbound scheme
An inbound scheme handles the calls of the channels it is bound to, and only while its status is active. GET /telephony/scheme-channels returns { "data": { "channels": [...] } }: the company channels able to take calls (SIP, WABA with calling enabled, personal WhatsApp in call-answering mode); an item has id, name, type, calling_did, country, status, has_scheme, scheme_id, scheme_name, scheme_status. POST /telephony/schemes/{scheme}/channels takes { "channel_ids": [int] }, the FULL set of the scheme channels (the field is required, an empty array unbinds all; channels outside the set are unbound); every channel must belong to your company, otherwise 422 (errors.channel_ids). POST /telephony/channels/{channel}/scheme takes { "scheme_id": "uuid" | null } (the field is required; null unbinds) and binds ONE channel; a foreign channel is 404 Channel not found.. Outbound schemes are not bound to channels (422, Outbound schemes are not bound to channels.): they are switched on through operator assignment (PUT /telephony/schemes/{scheme}/assignments). Both POSTs answer { "data": { "message": "..." } }.
Audio prompts
Greetings, IVR menus and hold music come from the company prompt library. GET /telephony/audio-prompts returns { "data": { "items": [ { "id", "name", "status", "original_filename", "created_at" } ] } }. POST /telephony/audio-prompts is multipart/form-data with name (up to 120 characters) and file (mp3, wav, ogg, oga, m4a, aac, webm or mp4, up to 15 MB). The file is transcoded for telephony and delivered to the telephony server synchronously: success is 201 { "data": { "item": {...} } } with status: "ready"; if transcoding or delivery fails, 422 with a message. Only a prompt in the ready status is played; play/ivr_menu/voicemail nodes pointing at a not-ready or foreign prompt are skipped. DELETE /telephony/audio-prompts/{prompt} answers { "data": { "message": "Prompt deleted." } } (a foreign one is 404). The id goes into prompt_id, greeting_id and moh_prompt_id.
Teams for a ring_group (members.teams) are created through /v1/teams; see the "Teams and users" group.
Call recording settings and retention
Three endpoints behind the «Что записываем» / "What we record" subsection (Telephony → Settings): GET and PATCH /v1/companies/current/telephony/recording-settings, plus GET /v1/companies/current/telephony/recording-settings/retention-preview. The company comes from the caller's current_company_id — there is no company identifier in the path at all, so another tenant cannot be addressed. The gate is the existing company_telephony.manage permission (every company's admin role holds it unconditionally); the feature introduces no new permission. A request carrying an embed token (the embed:softphone:* or embed:read ability) is refused outright, before any body validation: 403 { "data": { "message": "This action is unauthorized." } }. The telephony-recording-settings limiter allows 30 requests per minute per user across all three endpoints; exceeding it returns 429. GET and PATCH stay reachable with a lapsed subscription (this is the switch a tenant uses to STOP recording and deletion — losing it together with the subscription is the wrong failure mode); retention-preview does require an active subscription, being only a planning aid.
GET .../recording-settings answers { "data": { "record_calls_enabled": bool, "retention_months": int|null, "retention_options": [3, 6, 12], "retention_changed_at": iso|null, "retention_effective_at": iso|null, "retention_paused_reason": string|null, "last_changed_at": iso|null, "last_changed_by": { "user_id": int|null, "name": string|null } | null, "archive": { "bytes": int|null, "calls_count": int|null, "measured_at": iso|null } } }. A retention_months of null is a value, not a missing one: "never delete", and the default for every company, existing and new — retention only ever starts with an explicit choice. retention_options is the whitelist of terms in months that the UI offers and the PATCH accepts, one and the same list. retention_effective_at is when the nightly sweep will FIRST apply the current term (the end of the cooling-off window, not tonight); null while nothing is due to be deleted. retention_paused_reason explains why a set term is deleting nothing: cooling_off, subscription_inactive, company_blocked, company_deleted, invalid_term, missing_change_timestamp, company_missing — or null when the sweep will act on its next run. archive is a snapshot from the nightly measurer, never computed per request: while measured_at is null there is no size to show. The settings row is created lazily on the very first GET, but the response is always 200 (never 201), so the contract does not change shape depending on the company's history.
PATCH .../recording-settings takes a partial body — only the fields actually sent are touched, so a client that knows about one switch cannot blank a setting it has never heard of. Fields: record_calls_enabled (bool), retention_months (null, or one of retention_options; an arbitrary integer is a 422 ordinary validation error), plus the two confirmation flags confirm_retention_shortening and acknowledge_recording_notice (bool). A successful response has the same shape as the GET.
The two conditional refusals of the PATCH answer with a FLAT body { "message": ..., "error_code": ... } and status 422 — no data envelope, unlike an ordinary validation error ({ "data": { "message", "errors" } }): the frontend dispatches on the machine-readable code to open the right dialog.
recording_notice_acknowledgement_required— flippingrecord_calls_enabledfromfalsetotruewithoutacknowledge_recording_notice: true. The platform plays no automatic recording notice: a route greeting is an audio file the company uploaded itself, and outbound calls have no greeting at all. Switching recording on therefore requires acknowledging that notifying the caller is the customer company's obligation; the acknowledgement is written to the audit log together with the version of the wording. Switching recording OFF needs no confirmation.retention_shortening_requires_confirmation— shortening the term withoutconfirm_retention_shortening: true. "Shortening" covers both moving to a shorter term (12 → 3) AND moving from "never delete" to any term at all (null → 12) — the latter being the single largest deletion this setting can schedule. Lengthening (3 → 12), going back to "never delete" (12 → null) and re-picking the same term need no confirmation, which keeps a re-submitted form idempotent.
Side effects of a successful PATCH: any term change (a lengthening one included) restarts the cooling-off week — retention_changed_at is updated and the nightly sweep skips the company until the window closes; switching recording on or off, and enabling or shortening the term, alert EVERY administrator of the company in the notification centre (the only compensating control against a hijacked session — the feature introduces no separate "may destroy the archive" permission); every change writes an audit record (telephony.recording_settings.updated, plus telephony.recording.consent_acknowledged when recording is switched on) and is stamped on the settings row as last_changed_by/last_changed_at. Retention deletion is irreversible: the nightly run removes the recording file, the cached m4a derivative and the chat attachment on the call card, blanks the transcript and summary of the linked voice call, and leaves the call with recording_status: "expired". The run is capped per company per night, so a large archive shrinks over several nights rather than all at once.
GET .../recording-settings/retention-preview?months=3 — exactly what the chosen term would destroy. months is required and must come from the same whitelist (3, 6, 12), otherwise 422: there is no "preview the default", because the default (null, never delete) deletes nothing. The response: { "data": { "months": int, "affected_calls": int, "affected_bytes": int, "unmeasured_calls": int, "scrubbed_transcripts": int, "oldest_started_at": iso|null, "first_deletion_at": iso } }. It is computed from the same predicate the nightly sweep runs, so the dialog describes the deletion it authorises rather than an approximation. A non-zero unmeasured_calls means affected_bytes is an UNDERSTATEMENT (some recordings have no measured size yet) — show the count of recordings rather than a size in that case. scrubbed_transcripts counts the transcripts that will be blanked together with the recordings; a transcript of a call whose recording was never stored never falls under retention at all, so for a company that records nothing this number is always zero.
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; `channel_id` is optional — omitted, the operator's outbound route picks the line. | Originate an outbound call to a contact over a WhatsApp or SIP channel; `channel_id` is optional — omitted, the operator's outbound route picks the line. |
| 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. |
| GET | /v1/telephony/callbacksCallback queue: missed inbound calls grouped by the other party (period/state/page/perPage) + counters. | Callback queue: missed inbound calls grouped by the other party (period/state/page/perPage) + counters. |
| POST | /v1/telephony/callbacks/{call}/resolveTake a task off the callback queue: `{ "resolution": "called_back" | "dismissed" }`. | Take a task off the callback queue: `{ "resolution": "called_back" | "dismissed" }`. |
| 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). |
| GET | /v1/telephony/schemesList call-routing schemes (inbound/outbound); optional `direction=inbound|outbound` filter. | List call-routing schemes (inbound/outbound); optional `direction=inbound|outbound` filter. |
| GET | /v1/telephony/outbound-linePreview the line for the operator's next outbound call (`to_number` or `contact_id`) — the same resolver the call itself will use. | Preview the line for the operator's next outbound call (`to_number` or `contact_id`) — the same resolver the call itself will use. |
| GET | /v1/telephony/schemes/{scheme}/assignmentsWho (operators/teams) is assigned to an outbound scheme; the `conflicts` field is always empty since 2026-10-20. | Who (operators/teams) is assigned to an outbound scheme; the `conflicts` field is always empty since 2026-10-20. |
| PUT | /v1/telephony/schemes/{scheme}/assignmentsSet the full operator/team set of an outbound scheme (not a delta) — anything left out is unassigned; other schemes are untouched (`moved` is always empty). | Set the full operator/team set of an outbound scheme (not a delta) — anything left out is unassigned; other schemes are untouched (`moved` is always empty). |
| GET | /v1/companies/current/telephony/outbound-settingsThe company outbound policy: the shared number (`default_channel_id`) and the "choose the number before a call" switch (`manual_pick_enabled`). | The company outbound policy: the shared number (`default_channel_id`) and the "choose the number before a call" switch (`manual_pick_enabled`). |
| PATCH | /v1/companies/current/telephony/outbound-settingsChange the shared number and/or the number-choice switch (any subset of fields); a foreign or unusable line gives `422 default_channel_invalid`. | Change the shared number and/or the number-choice switch (any subset of fields); a foreign or unusable line gives `422 default_channel_invalid`. |
| POST | /v1/telephony/schemesCreate a scheme (`name`, `direction` inbound|outbound, `timezone`, `comment`): a draft with a single entry node. | Create a scheme (`name`, `direction` inbound|outbound, `timezone`, `comment`): a draft with a single entry node. |
| GET | /v1/telephony/schemes/{scheme}The whole scheme (builder): settings, bound channels, nodes and edges. | The whole scheme (builder): settings, bound channels, nodes and edges. |
| PATCH | /v1/telephony/schemes/{scheme}Change scheme settings: `name`, `comment`, `timezone`, `status` (`active` turns it on), `outbound_priority`, `moh_as_ringback`, `moh_prompt_id`. `direction` is immutable. | Change scheme settings: `name`, `comment`, `timezone`, `status` (`active` turns it on), `outbound_priority`, `moh_as_ringback`, `moh_prompt_id`. `direction` is immutable. |
| DELETE | /v1/telephony/schemes/{scheme}Delete a scheme. | Delete a scheme. |
| PATCH | /v1/telephony/schemes/{scheme}/builderReplace the scheme graph in full: `nodes[]` and `edges[]` (exactly one entry node, node types by direction). | Replace the scheme graph in full: `nodes[]` and `edges[]` (exactly one entry node, node types by direction). |
| POST | /v1/telephony/schemes/{scheme}/channelsSet the full channel set of an inbound scheme (`channel_ids[]`); anything left out is unbound. | Set the full channel set of an inbound scheme (`channel_ids[]`); anything left out is unbound. |
| POST | /v1/telephony/channels/{channel}/schemeBind one channel to an inbound scheme (`scheme_id`) or unbind it (`null`). | Bind one channel to an inbound scheme (`scheme_id`) or unbind it (`null`). |
| GET | /v1/telephony/scheme-channelsCompany channels able to take calls, with their current scheme. | Company channels able to take calls, with their current scheme. |
| GET | /v1/telephony/audio-promptsThe company audio-prompt library (`id`, `name`, `status`). | The company audio-prompt library (`id`, `name`, `status`). |
| POST | /v1/telephony/audio-promptsUpload a prompt (`multipart`: `name`, `file` up to 15 MB); plays once `ready`. | Upload a prompt (`multipart`: `name`, `file` up to 15 MB); plays once `ready`. |
| DELETE | /v1/telephony/audio-prompts/{prompt}Delete an audio prompt. | Delete an audio prompt. |
| GET | /v1/companies/current/telephony/recording-settingsThe company's call recording settings: whether recording is on, the retention term (`null` = never delete), why the sweep is paused, and the archive size snapshot. | The company's call recording settings: whether recording is on, the retention term (`null` = never delete), why the sweep is paused, and the archive size snapshot. |
| PATCH | /v1/companies/current/telephony/recording-settingsChange the recording settings (partial body): the recording switch and the retention term; switching recording on and shortening the term each require a confirmation flag. | Change the recording settings (partial body): the recording switch and the retention term; switching recording on and shortening the term each require a confirmation flag. |
| GET | /v1/companies/current/telephony/recording-settings/retention-previewHow many recordings, bytes and transcripts the chosen term would destroy (`months` from the whitelist) and when the first deletion happens. | How many recordings, bytes and transcripts the chosen term would destroy (`months` from the whitelist) and when the first deletion happens. |
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
}
}
}A call with no channel_id — the outbound route picks the line
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
}'Response
{
"data": {
"call": {
"id": 5003,
"direction": "outbound",
"status": "ringing",
"to_number": "7700000XXXX",
"from_number": "7727000XXXX",
"asterisk_channel_id": "1720787200.4540",
"timeline_message_id": 88230
}
}
}A call with no channel_id, forbidden by the route (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
}'Response
{
"data": {
"message": "This call is not allowed by your outbound route.",
"block_reason": "international_not_allowed",
"eligibility": null
}
}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",
"timezone": "Asia/Almaty",
"durationSec": 51
}
}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?company=3&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."
}
}Callback queue
Request
curl -G "https://api.aisar.app/v1/telephony/callbacks" \
--data-urlencode "period=7d" \
--data-urlencode "state=pending" \
--data-urlencode "perPage=20" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"call_id": 5121,
"contact": {
"id": 812,
"display_name": "Алия Сериковна",
"avatar_url": null
},
"from_number": "+12135550111",
"channel_id": 41,
"channel_name": "SIP — основной номер",
"conversation_id": null,
"missed_at": "2026-09-09T19:42:10+00:00",
"waited_sec": 27,
"attempts": 3,
"outside_working_hours": true,
"resolution": null
},
{
"call_id": 5118,
"contact": null,
"from_number": "+12135550166",
"channel_id": 41,
"channel_name": "SIP — основной номер",
"conversation_id": null,
"missed_at": "2026-09-09T11:05:02+00:00",
"waited_sec": 12,
"attempts": 1,
"outside_working_hours": false,
"resolution": null
}
],
"pagination": { "page": 1, "perPage": 20, "total": 2, "lastPage": 1 },
"counters": { "pending": 2, "resolved_today": 5 },
"timezone": "Asia/Almaty"
}
}Resolve a callback queue task
Request
curl -X POST "https://api.aisar.app/v1/telephony/callbacks/5121/resolve" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "resolution": "called_back" }'Response
{
"data": {
"call_id": 5121,
"resolution": {
"type": "called_back",
"at": "2026-09-10T08:15:44+00:00",
"by_user": { "id": 55, "name": "Асем" },
"call_id": null,
"duration_sec": null
}
}
}The task is already closed (409)
Request
curl -X POST "https://api.aisar.app/v1/telephony/callbacks/5118/resolve" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "resolution": "dismissed" }'Response
{
"data": {
"message": "Callback already resolved."
}
}Call stats for the last 7 days
Request
curl -G "https://api.aisar.app/v1/telephony/stats" \
--data-urlencode "period=7d" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"from": "2026-09-03T19:00:00+00:00",
"to": "2026-09-10T18:59:59+00:00",
"timezone": "Asia/Almaty",
"total": 214,
"answered": 173,
"missed": 29,
"missed_not_called_back": 4,
"avg_talk_sec": 168,
"service_level": 82.5,
"service_level_threshold_sec": 20,
"ai_share": 21.4,
"directions": { "inbound": 141, "outbound": 73 },
"waiting_buckets": {
"median_sec": 12,
"buckets": [
{ "from_sec": 0, "to_sec": 10, "count": 48 },
{ "from_sec": 10, "to_sec": 20, "count": 41 },
{ "from_sec": 20, "to_sec": 40, "count": 19 },
{ "from_sec": 40, "to_sec": null, "count": 4 }
]
},
"by_day": [
{ "date": "2026-09-09", "answered": 27, "missed": 5 },
{ "date": "2026-09-10", "answered": 24, "missed": 3 }
],
"by_hour": [
{ "hour": 9, "total": 18, "answered": 11, "missed": 2 },
{ "hour": 10, "total": 23, "answered": 15, "missed": 3 }
],
"by_channel": [
{ "channel_id": 41, "channel_name": "SIP — основной номер", "provider": "sip", "total": 186 },
{ "channel_id": 7, "channel_name": "WhatsApp — Sales", "provider": "whatsapp", "total": 28 }
],
"by_assignee": [
{ "type": "operator", "id": 9, "name": "Данияр О.", "answered": 71, "missed": 12, "avg_talk_sec": 154 },
{ "type": "operator", "id": 14, "name": "Асем Б.", "answered": 65, "missed": 11, "avg_talk_sec": 181 },
{ "type": "ai_agent", "id": 3, "name": "Ассистент приёмной", "answered": 37, "missed": 0, "avg_talk_sec": 96 },
{ "type": null, "id": null, "name": null, "answered": 0, "missed": 6, "avg_talk_sec": null }
]
}
}Searching a word across call transcripts
Request
curl -G "https://api.aisar.app/v1/calls" \
--data-urlencode "q=возврат" \
--data-urlencode "q_in=all" \
--data-urlencode "date_from=2026-09-01" \
--data-urlencode "lazy_recordings=1" \
--data-urlencode "perPage=20" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"items": [
{
"id": 5121,
"direction": "inbound",
"status": "completed",
"provider": "sip",
"from_number": "7727000XXXX",
"to_number": "7008550XXXX",
"started_at": "2026-09-09T09:14:00+00:00",
"answered_at": "2026-09-09T09:14:08+00:00",
"ended_at": "2026-09-09T09:18:26+00:00",
"duration_sec": 258,
"contact": {
"id": 118,
"display_name": "Nurlan T.",
"avatar_url": null
},
"channel_id": 41,
"channel_name": "SIP — основной номер",
"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": "Данияр О."
},
"transcript_matches": [
{
"at_sec": 34.5,
"role": "user",
"text": "…хочу оформить возврат по вчерашнему заказу…",
"highlights": [
{ "start": 15, "end": 22 }
]
}
]
}
],
"pagination": {
"page": 1,
"perPage": 20,
"total": 1,
"lastPage": 1
},
"meta": {
"search_truncated": false,
"search_cap": 500
}
}
}Transcript search with no query term (422)
Request
curl -G "https://api.aisar.app/v1/calls" \
--data-urlencode "q_in=transcript" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"message": "A search term is required when q_in is transcript or all.",
"errors": {
"q": [
"A search term is required when q_in is transcript or all."
]
}
}
}List outbound schemes
Request
curl -G "https://api.aisar.app/v1/telephony/schemes" \
--data-urlencode "direction=outbound" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": [
{
"id": "0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c31",
"direction": "outbound",
"name": "Продажи",
"comment": null,
"status": "active",
"timezone": "Asia/Almaty",
"outbound_priority": 100,
"channels_count": 0,
"assigned_operators_count": 2,
"assigned_teams_count": 1,
"outbound_line": {
"channel_id": 41,
"name": "Городской 727",
"number": "+7727XXXXXXX"
},
"node_count": 3,
"updated_at": "2026-09-14T09:12:00+00:00"
}
]
}Preview the dial-out line
Request
curl -G "https://api.aisar.app/v1/telephony/outbound-line" \
--data-urlencode "to_number=+12125550181" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"scheme": {
"id": "0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c31",
"name": "Продажи"
},
"blocked": false,
"block_reason": null,
"candidates": [
{
"channel_id": 41,
"name": "Городской 727",
"dial_mode": "dial_direct",
"is_default": true
},
{
"channel_id": 52,
"name": "Резервная линия",
"dial_mode": "dial_direct",
"is_default": false
}
],
"rejected": [
{ "channel_id": 9, "reason": "channel_not_connected" }
],
"allowed_channel_ids": [41, 52]
}
}Line preview from an embed widget (trimmed response)
Request
# Bearer token here carries an "embed:softphone:*" or "embed:read" ability
curl -G "https://api.aisar.app/v1/telephony/outbound-line" \
--data-urlencode "to_number=+12125550181" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"blocked": false,
"block_reason": null,
"default_channel_id": 41,
"dial_mode": "dial_direct",
"allowed_channel_ids": null
}
}Assign operators and a team to an outbound scheme
Request
curl -X PUT "https://api.aisar.app/v1/telephony/schemes/0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c31/assignments" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "operator_ids": [14, 27], "team_ids": [5] }'Response
{
"data": {
"assignments": {
"operators": [
{ "id": 14, "name": "Данияр О." },
{ "id": 27, "name": "Асем Б." }
],
"teams": [
{ "id": 5, "name": "Продажи" }
],
"conflicts": []
},
"moved": {
"operators": [],
"teams": []
}
}
}Company outbound policy: read and change
Request
curl -X PATCH "https://api.aisar.app/v1/companies/current/telephony/outbound-settings" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "default_channel_id": 412, "manual_pick_enabled": false }'Response
{
"data": {
"default_channel_id": 412,
"default_channel": { "id": 412, "name": "Городской номер", "number": "+77010000000" },
"default_channel_available": true,
"manual_pick_enabled": false
}
}A call with a number outside the primary scheme while the choice is off (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": 9051, "channel_id": 413 }'Response
{
"data": {
"message": "Choosing the number before a call is turned off for this company.",
"error_code": "manual_pick_disabled"
}
}Trying to assign operators to an inbound scheme (422)
Request
curl -X PUT "https://api.aisar.app/v1/telephony/schemes/0199a1e2-99aa-7f4e-9c2b-6a1d8e7b1c00/assignments" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "operator_ids": [14], "team_ids": [] }'Response
{
"data": {
"message": "The given data was invalid.",
"errors": {
"scheme": [
"Only outbound schemes have operator assignments."
]
}
}
}The company's call recording settings
Request
curl "https://api.aisar.app/v1/companies/current/telephony/recording-settings" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"record_calls_enabled": true,
"retention_months": null,
"retention_options": [3, 6, 12],
"retention_changed_at": null,
"retention_effective_at": null,
"retention_paused_reason": null,
"last_changed_at": "2026-09-18T09:14:03.000000Z",
"last_changed_by": {
"user_id": 14,
"name": "Данияр О."
},
"archive": {
"bytes": 1157627904,
"calls_count": 776,
"measured_at": "2026-09-22T03:10:11.000000Z"
}
}
}Start deleting recordings older than 6 months
Request
curl -X PATCH "https://api.aisar.app/v1/companies/current/telephony/recording-settings" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"retention_months": 6,
"confirm_retention_shortening": true
}'Response
{
"data": {
"record_calls_enabled": true,
"retention_months": 6,
"retention_options": [3, 6, 12],
"retention_changed_at": "2026-09-22T11:02:47.000000Z",
"retention_effective_at": "2026-09-30T02:40:00.000000Z",
"retention_paused_reason": "cooling_off",
"last_changed_at": "2026-09-22T11:02:47.000000Z",
"last_changed_by": {
"user_id": 14,
"name": "Данияр О."
},
"archive": {
"bytes": 1157627904,
"calls_count": 776,
"measured_at": "2026-09-22T03:10:11.000000Z"
}
}
}The same request without the confirmation (422, flat body)
Request
curl -X PATCH "https://api.aisar.app/v1/companies/current/telephony/recording-settings" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "retention_months": 6 }'Response
{
"message": "Shortening the retention term deletes recordings irreversibly and requires an explicit confirmation.",
"error_code": "retention_shortening_requires_confirmation"
}What a 6-month term would destroy (preview)
Request
curl -G "https://api.aisar.app/v1/companies/current/telephony/recording-settings/retention-preview" \
--data-urlencode "months=6" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Response
{
"data": {
"months": 6,
"affected_calls": 128,
"affected_bytes": 193273528,
"unmeasured_calls": 0,
"scrubbed_transcripts": 11,
"oldest_started_at": "2026-02-14T07:21:09.000000Z",
"first_deletion_at": "2026-09-30T02:40:00.000000Z"
}
}Create an inbound scheme
Request
curl -X POST "https://api.aisar.app/v1/telephony/schemes" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Основная линия", "direction": "inbound", "timezone": "Asia/Almaty" }'Response
{
"data": {
"scheme": {
"id": "0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40",
"direction": "inbound",
"outbound_priority": 100,
"channels": [],
"name": "Основная линия",
"comment": null,
"timezone": "Asia/Almaty",
"status": "draft",
"moh_as_ringback": false,
"moh_prompt_id": null,
"nodes": [
{
"id": "0199a1e2-7c3b-7a10-8d11-2b3c4d5e6f70",
"type": "entry",
"position": { "x": 80, "y": 160 },
"data": []
}
],
"edges": []
}
}
}Upload an audio prompt
Request
curl -X POST "https://api.aisar.app/v1/telephony/audio-prompts" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "name=Приветствие" \
-F "file=@greeting.mp3"Response
{
"data": {
"item": {
"id": 12,
"name": "Приветствие",
"status": "ready",
"original_filename": "greeting.mp3",
"created_at": "2026-10-16T08:30:00+00:00"
}
}
}An inbound graph: greeting, schedule, IVR 1/2, two ring groups, voicemail
Request
curl -X PATCH "https://api.aisar.app/v1/telephony/schemes/0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40/builder" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"nodes": [
{ "id": "n_entry", "type": "entry", "position": { "x": 80, "y": 160 } },
{ "id": "n_hello", "type": "play", "position": { "x": 320, "y": 160 }, "data": { "prompt_id": 12 } },
{ "id": "n_hours", "type": "schedule", "position": { "x": 560, "y": 160 }, "data": { "intervals": [
{ "weekday": 1, "start": "09:00", "end": "18:00" },
{ "weekday": 2, "start": "09:00", "end": "18:00" },
{ "weekday": 3, "start": "09:00", "end": "18:00" },
{ "weekday": 4, "start": "09:00", "end": "18:00" },
{ "weekday": 5, "start": "09:00", "end": "18:00" }
] } },
{ "id": "n_menu", "type": "ivr_menu", "position": { "x": 800, "y": 120 }, "data": { "prompt_id": 13, "digits": ["1", "2"], "timeout_sec": 6, "repeats": 2 } },
{ "id": "n_sales", "type": "ring_group", "position": { "x": 1040, "y": 40 }, "data": { "members": { "operators": [], "teams": [3] }, "strategy": "all", "timeout_sec": 25 } },
{ "id": "n_support", "type": "ring_group", "position": { "x": 1040, "y": 200 }, "data": { "members": { "operators": [9, 14], "teams": [] }, "strategy": "sequential", "timeout_sec": 20 } },
{ "id": "n_vm", "type": "voicemail", "position": { "x": 1280, "y": 160 }, "data": { "greeting_id": 14, "max_sec": 120 } }
],
"edges": [
{ "source": "n_entry", "target": "n_hello", "sourceHandle": "out" },
{ "source": "n_hello", "target": "n_hours", "sourceHandle": "out" },
{ "source": "n_hours", "target": "n_menu", "sourceHandle": "work" },
{ "source": "n_hours", "target": "n_vm", "sourceHandle": "off" },
{ "source": "n_menu", "target": "n_sales", "sourceHandle": "digit_1" },
{ "source": "n_menu", "target": "n_support", "sourceHandle": "digit_2" },
{ "source": "n_menu", "target": "n_vm", "sourceHandle": "no_input" },
{ "source": "n_sales", "target": "n_vm", "sourceHandle": "no_answer" },
{ "source": "n_support", "target": "n_vm", "sourceHandle": "no_answer" }
]
}'Response
{
"data": {
"scheme": {
"id": "0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40",
"direction": "inbound",
"status": "draft",
"nodes": ["... 7 узлов с новыми uuid / 7 nodes with fresh uuids ..."],
"edges": ["... 9 рёбер / 9 edges ..."]
}
}
}Bind a channel and turn the scheme on
Request
curl -X POST "https://api.aisar.app/v1/telephony/schemes/0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40/channels" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "channel_ids": [41] }'
curl -X PATCH "https://api.aisar.app/v1/telephony/schemes/0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'Response
{
"data": {
"message": "Scheme channels updated."
}
}