Перейти к содержимому
AISARAISAR
REST API

Звонки

Журнал звонков компании (GET /calls, GET /calls/counters) и программное управление голосовыми звонками: инициировать исходящий вызов к контакту (POST /calls/originate) и завершить активный вызов (POST /calls/{call}/hangup). Это API управления звонками, а не браузерный софтфон — оно лишь ставит вызов в работу через телефонный шлюз AISAR; сам звук идёт по SIP/WebRTC вне HTTP. Также включает «Пульт супервайзера» (/v1/telephony/*): активные звонки, presence команды, wallboard, историческую статистику, очередь перезвона по пропущенным и действия супервайзера над живым звонком (listen/whisper/barge/transfer). И часть редактора схем маршрутизации звонков: список схем с фильтром по направлению, превью линии дозвона для оператора (GET /telephony/outbound-line) и назначение операторов/команд на исходящую схему. А также полный цикл управления схемами: создание, редактирование, граф узлов (builder), привязка каналов к входящей схеме и библиотека аудио-приглашений.

Доступ и требования

Вся группа доступна только при активной подписке и включённом на плане функционале телефонии (telephony); кроме того, вызывающий пользователь должен быть заведён как оператор телефонии (иметь провиженный SIP-эндпоинт) и обладать правом telephony.use в текущей компании — иначе 403 { "data": { "message": "Telephony is not available for your account." } }.

С 2026-09-08 из журнала звонков и его счётчиков исключены звонки контактов, скрытых из рабочего inbox (POST /v1/contacts/{contact}/hide-from-inbox) — в том числе для супервизоров с правом company_telephony.manage; уведомление о пропущенном звонке по такому контакту тоже не отправляется. С 2026-09-29 то же правило действует и по прямому id: GET /calls/{call}/recording-url (в том числе via=download) и GET /calls/{call}/transcript (расшифровка и сводка) для звонка скрытого контакта отвечают 404 — как для звонка другой компании, до проверки участника и права company_telephony.manage, то есть одинаково для любого пользователя. Уже выданные ссылки на запись такого звонка тоже перестают проигрываться (404), а в галереях вложений диалога и контакта запись не показывается. Ничего не удаляется: после возврата контакта в inbox запись и расшифровка снова доступны.

С 2026-09-26 явно выбранная линия (channel_id), закрытая для оператора правилами доступа к каналам (channel_access), отклоняется так же, как несуществующая: POST /calls/originate — 404 «Contact or channel not found.», POST /softphone/external-call и POST /contacts/{contact}/call-permission/request, GET /contacts/{contact}/outbound-eligibility — 404 «Channel not found.». Автоматический выбор линии по-прежнему делает сервер.

Инициировать вызов (`POST /calls/originate`)

POST /calls/originate принимает contact_id (обязателен, целое) и channel_id (с 2026-09-14 необязателен — исходящие схемы маршрутизации звонков, см. группу Телефония ниже). Канал задаёт способ дозвона: канал WhatsApp/WABA звонит контакту в WhatsApp, а канал типа sip_telephony набирает телефонный номер контакта через SIP-транк. Если channel_id передан, он используется, когда это разрешено схемой оператора и политикой компании (подробности и коды отказа — в разделе «Выбор номера: три ступени» ниже) — и контакт, и канал должны принадлежать текущей компании, иначе 404 { "data": { "message": "Contact or channel not found." } }. Если channel_id не передан, линию выбирает тот же резолвер, что отвечает на GET /telephony/outbound-line: у вызывающего оператора есть исходящий маршрут — звонок уходит по нему; маршрута нет — звонок уходит так же, как до появления этой функции. Маршрут запретил звонок или не нашёл ни одной доступной линии → 422 { "data": { "message": "...", "block_reason": string|null } } (в первом случае) или 422 { "data": { "message": "No line available for this call." } } (во втором). Параметр device (необязателен, с 2026-09-22: browser | native) задаёт, на какое устройство оператора придёт операторская нога: browser — браузерный софтфон / встраиваемый iframe (единственное поведение до этой даты), native — нативный SIP-профиль оператора (agent-{id}: SIP-приложение партнёра, Zoiper, настольный телефон). Без device нога идёт в браузер, если у оператора есть зарегистрированная вкладка, иначе — на нативный профиль, если зарегистрирован он. INVITE операторской ноги несёт заголовки X-AISAR-Call-Id (= call.id из ответа) и X-AISAR-Leg: outbound, по которым SIP-клиент отличает эхо собственного звонка от входящего.

Ответ и статусы звонка

Успех (200) возвращает созданную запись звонка: { "data": { "call": { id, direction: "outbound", status: "ringing", to_number, from_number, asterisk_channel_id, timeline_message_id } } } — вызов создаётся в состоянии ringing, а в таймлайн диалога добавляется карточка звонка (timeline_message_id). Дальнейшие переходы статуса (ringing → answered → completed, либо rejected / missed / failed) приходят через события реального времени и вебхуки семейства call.*, а не в теле этого ответа.

Разрешение на звонок (WhatsApp и SIP)

WhatsApp-звонки требуют действующего разрешения на звонок (call permission) от контакта: без него originate вернёт 422 с блоком eligibility, где call.can_call = false. Проверить готовность и запросить разрешение заранее можно через GET /v1/contacts/{contact}/outbound-eligibility и POST /v1/contacts/{contact}/call-permission/request (группа «Контакты»). Для SIP-каналов разрешение не требуется (eligibility.call.permission_status = "not_required"), нужен лишь телефонный номер у контакта — иначе 422 { "data": { "message": "Contact has no phone number to dial." } }.

Прочие исходы originate

Прочие исходы originate:

  • 403 с блоком eligibility — канал или контакт не поддерживает звонок (нет WhatsApp-аккаунта, оператор не включён).
  • 422 { "data": { "message": "No registered native SIP device for this operator." } } — запрошен device: "native", а нативное устройство оператора не зарегистрировано; клиента при этом никто не набирает.
  • 502 — телефонный шлюз не смог инициировать вызов.
  • 422 — ошибка валидации тела (отсутствуют contact_id / channel_id).
  • Канал с sip_outbound_gated: true (см. группу «Каналы») пропускается при выборе SIP-канала для звонка — если это единственный SIP-канал компании, originate ведёт себя так, как будто подключённого SIP-канала нет вовсе.
  • Только для sip_telephony-канала в режиме подключения по IP-адресу: дополнительная защита от злоупотреблений — запрещённый префикс номера или превышение лимита в 300 исходящих звонков в час на канал — возвращает 422 с сообщением "This destination cannot be dialled from this line." либо "Too many calls placed on this line recently. Try again later.". Каналы, подключённые по логину/паролю, этой проверке не подвергаются.

Завершение вызова (`POST /calls/{call}/hangup`)

POST /calls/{call}/hangup завершает активный вызов. Помимо доступности телефонии, завершить вызов может только его участник (назначенный оператор либо сторона вызова) или пользователь с правом управления телефонией компании — иначе 403. Успех: { "message": "Hangup requested." } (запрос на разрыв передан в шлюз; фактическое завершение подтверждается событием call.*). Обратите внимание: ответ hangup, в отличие от originate, не обёрнут в data.

Расшифровка звонка (`GET /calls/{call}/transcript`)

GET /calls/{call}/transcript отдаёт расшифровку и сводку голосовой ИИ-сессии (ElevenLabs), привязанной к звонку. У эндпоинта СВОЙ гейт доступа, отдельный от общего требования группы — telephony.use и провиженный SIP-аккаунт здесь не обязательны. Достаточно любого из трёх условий: (а) вызывающий — участник звонка (назначенный оператор либо сторона вызова, та же проверка, что у hangup); (б) у вызывающего есть право company_telephony.manage; (в) звонок принял ИИ-агент (assignee_type = "ai_agent") и у вызывающего есть conversation.view в диалоге этого звонка, причём с 2026-09-25 сам диалог должен быть ему виден по правилам доступа к каналам (channel_access команд; иначе — 403) — так расшифровку может открыть любой оператор, читающий этот диалог, а не только супервайзер. Для звонка, принятого человеком, conversation.view доступа не даёт: такую расшифровку видит только сам участник или менеджер телефонии.

Компания сверяется первой, до любой другой проверки: чужой либо несуществующий {call} — 404 (как будто записи не существует), а не 403. Если компания совпала, но ни одна из трёх веток доступа не выполнена — 403. Если у звонка вообще нет голосовой сессии (обычный звонок без ИИ-агента) — тоже 404: { "data": { "message": "No voice session for this call." } }.

Успешный ответ (200): { "data": { voiceCallId, callId, status, outcome, summary, transcript: [...], startedAt, answeredAt, endedAt, timezone, durationSec } } — обратите внимание, поля этого ответа в camelCase, а не в snake_case, как в остальной группе. voiceCallId — UUID голосовой сессии, callId — id звонка (совпадает с {call} из пути). status — состояние сессии (например dialing/in_progress/completed/failed). outcome — resolved/failed/null, если вендор не определил исход. summary — текстовая сводка звонка либо null. startedAt/answeredAt/endedAt — ISO-8601 либо null. timezone — IANA-часовой пояс компании, в котором эти метки времени нужно показывать пользователю. durationSec — длительность разговора в секундах либо null; она берётся у самого звонка, а не из тарифицируемых секунд голосовой сессии, поэтому совпадает с duration_sec той же строки журнала GET /calls.

Каждый элемент transcript[] — реплика: role (строка, user/agent/…), text, at_sec (секунда звонка, число) и tool_calls[] — вызовы инструментов ИИ-агента на этой реплике. Каждый элемент tool_calls[] отдаёт фиксированный набор полей: type, tool_name, request_id, params_as_json (строка — аргументы вызова как прислал вендор), tool_has_been_called (bool), result (результат инструмента: разобранный JSON, сырая строка или null, если результат не зафиксирован), is_error (bool либо null; null значит «результат не зафиксирован», а НЕ успех), error (текст ошибки вендора либо null) и latency_sec (число секунд либо null). Вендор может прислать вызов инструмента и его результат на разных репликах — API уже сопоставляет их по request_id и отдаёт результат на той же реплике, где случился сам вызов.

Ссылка на запись звонка (`GET /calls/{call}/recording-url`)

GET /calls/{call}/recording-url минтит одноразовую подписанную ссылку на аудиозапись звонка — точечный аналог поля recording_url в журнале (GET /calls), только выпущенный по явному клику, а не заранее для каждой строки списка. Гейт доступа полностью идентичен transcript (см. выше): участник звонка, company_telephony.manage или читатель диалога ИИ-звонка через conversation.view; тот же порядок 404 (чужая компания) → 403 (нет права). Если для звонка нет сохранённой записи (recording_status не stored, либо запись не удалось сопоставить) — 404 { "data": { "message": "No recording for this call." } }.

Успешный ответ (200): { "data": { "recording_url": "<подписанная ссылка>" } } — ссылка живёт 15 минут (короче, чем 30-минутная ссылка из списка журнала, так как минтится по прямому клику «слушать/скачать», а не заранее). Необязательный query-параметр via=play|download (по умолчанию play) — download добавляет Content-Disposition: attachment, заставляя браузер скачать файл вместо проигрывания. Лимит — 30 запросов в минуту на пользователя.

Журнал звонков (`GET /calls`)

GET /calls возвращает журнал звонков компании в конверте { "data": { "items": [...], "pagination": {...} } } (конвенция листинговых эндпоинтов этого API, НЕ конверт { "data": [...] }). По умолчанию оператор видит только звонки, к которым он причастен (назначенный, звонящий или принимающий сторона); право company_telephony.manage расширяет видимость до всех звонков компании — это одно и то же ограничение видимости, что используется в GET /calls/counters, так что счётчики никогда не разойдутся со списком. Базовые фильтры (все необязательные query-параметры): filter=all|missed (по умолчанию all; missed — непринятые входящие звонки), direction=inbound|outbound, channel_id (целое, должен существовать), q (строка до 100 символов — регистронезависимый поиск подстроки по from_number/to_number и имени контакта), assignee_type=operator|ai_agent, page (≥1), perPage (1–100, по умолчанию 20). Сортировка — по убыванию started_at. Лимит: 120 запросов в минуту на пользователя (лимитер telephony-calls-index, добавлен вместе с архивными фильтрами ниже — до этого у GET /calls не было отдельного троттлинга); при превышении — 429.

Фильтры архива записей (`GET /calls`, Ф3c)

Тот же GET /calls поддерживает дополнительный набор необязательных query-параметров для режима архива «Записи» — если их не передавать, поведение журнала (в т.ч. используемого во встроенном чате) не меняется: has_recording=1 — только звонки с сохранённой записью (recording_status = "stored"); assigned_user_id (целое) — звонки конкретного оператора (значение не проверяется на существование — id из чужой компании просто не даёт совпадений, а не 404/422, т.к. видимость и так ограничена текущей компанией); date_from/date_to (даты YYYY-MM-DD, date_to >= date_from) — календарные даты, резолвятся в часовом поясе компании и переводятся в UTC-границы суток, как в GET /telephony/stats; with_summary=1 — добавляет в ответ блок summary по ВСЕМУ отфильтрованному набору (см. ниже), а не только по текущей странице; lazy_recordings=1 — не подписывать recording_url заранее для каждой строки списка (поле будет null у всех звонков, включая те, где есть запись) — ссылка выдаётся по клику через GET /calls/{call}/recording-url. Фильтр channel_id (целое) не проверяется на существование: чужой или несуществующий id даёт 200 с пустым data.items, нечисловое значение — 422.

При with_summary=1 ответ дополнительно содержит { "data": { ..., "summary": { "count": N, "total_duration_sec": S } } } — количество звонков и суммарная длительность в секундах по ВСЕМ звонкам, подходящим под текущий набор фильтров (не только по звонкам на странице). Блок summary отсутствует в ответе, если with_summary не передан.

Каждый элемент items[] — объект звонка: id, direction, status (ringing/answered/completed/rejected/missed/failed), provider (whatsapp/sip/internal), from_number, to_number, временные метки started_at/answered_at/ended_at (ISO-8601 либо null), duration_sec, вложенные contact (id, display_name, avatar_url) и assignee (id, name), channel_id/channel_name/channel_type (слаг типа канала, ChannelType::key, например whatsapp_business или sip_telephony), conversation_id, timeline_message_id, recording_status, recording_url, has_recording (bool, = recording_status === "stored") и has_transcript (bool — есть ли у звонка голосовая ИИ-сессия, т.е. вернёт ли GET /calls/{call}/transcript диалог, а не 404). recording_url — временная подписанная ссылка (TTL ~30 минут); присутствует, только когда recording_status = "stored" И запрос НЕ передавал lazy_recordings=1 — при lazy_recordings=1 поле всегда null, даже у звонков с записью (используйте вместо него GET /calls/{call}/recording-url по клику). has_recording, в отличие от recording_url, не зависит от lazy_recordings и остаётся достоверным индикатором наличия записи в обоих режимах. pagination содержит { page, perPage, total, lastPage }.

Фильтры-переходы из статистики (`GET /calls`)

Ещё один набор необязательных query-параметров GET /calls — переходы из графиков статистики и из уведомления о пропущенном звонке. Все они аддитивны: запрос без них ведёт себя ровно как раньше.

  • status — сырой статус звонка: ringing, answered, completed, rejected, missed, failed, answered_elsewhere. Он строже, чем filter=missed (тот дополнительно ограничен входящими); если передать оба, применяются оба.
  • hour_from/hour_to (целые 0–23, включительно с обеих сторон) — час НАЧАЛА звонка в локальном часовом поясе компании, тот же, по которому построен ряд by_hour в статистике, так что клик по столбцу графика открывает ровно те звонки, из которых он сложен. Можно передать только один из двух. hour_to < hour_from — законный интервал через полночь: hour_from=18&hour_to=2 — ночная смена.
  • unassigned=1 — звонки без назначенного оператора И без ИИ-агента. Это отдельный булев параметр, а не assigned_user_id=null: в HTTP строка «null» неотличима от контакта, который так и называется.
  • callback_state=pending|resolved — пропущенные входящие, ещё открытые либо уже закрытые в очереди перезвона. Предикат тот же, что у GET /telephony/callbacks (входящий + пропущенный + состояние), поэтому журнал и очередь не разойдутся.
  • has_transcript=1 — только звонки, у которых есть что читать (расшифровка сохранена). Обратите внимание: фильтр строже, чем одноимённое поле has_transcript в строке ответа — поле true уже при наличии голосовой сессии, даже если её расшифровка так и не доехала.
  • assignee_type=operator|ai_agent — кто взял звонок; параметр существовал и раньше, теперь это ещё и переход из строки by_assignee.

Поиск по расшифровкам (`GET /calls?q_in=`)

Параметр q_in задаёт, где ищется значение q: contact (по умолчанию — номера и имя контакта, прежнее поведение), transcript (текст расшифровок голосовых звонков) или all (и там, и там). Поиск по расшифровкам полнотекстовый, а не подстрочный: слова ищутся целиком, без морфологии; при all два набора совпадений объединяются по идентификаторам звонков. Все остальные фильтры запроса (период, канал, оператор, часы) применяются к поиску тоже — искать всегда ищется внутри уже отфильтрованного журнала.

Поиск по расшифровкам — отдельная, более дорогая ветка, у неё свои ограничения:

  • При q_in=transcript|all параметр q обязателен и не может быть пустым — иначе 422. Пустой запрос по расшифровкам был бы не поиском, а выгрузкой всех разговоров компании.
  • Свой лимит: 20 запросов в минуту на пользователя (обычный журнал по-прежнему 120/мин); при превышении — 429.
  • Свой бюджет времени: если поиск не уложился примерно в 5 секунд, он снимается и возвращается 504 { "data": { "message": "Transcript search took too long — try a longer or more specific query." } }. Лечится сужением периода или уточнением запроса.
  • Токены встраивания (embed) к поиску по расшифровкам не допускаются: q_in=transcript|all с таким токеном — 403. Сам журнал, архив записей и поиск по умолчанию (q_in=contact) остаются встроенному чату доступны как прежде.
  • Каждая ветка поиска отдаёт не более 500 совпадений; если лимит выбран, ответ скажет об этом через meta.search_truncated.

При q_in=transcript|all ответ дополнен блоком meta — { "search_truncated": bool, "search_cap": 500 } (у обычного журнала блока meta нет). У тех строк items[], где совпадение нашлось в расшифровке, появляется transcript_matches[] — до трёх коротких цитат: at_sec (секунда звонка, на которую нужно перемотать плеер, число), role (кто говорит), text (обычный текст, без HTML-разметки; длинная реплика обрезается) и highlights — [{ start, end }], смещения найденных слов внутри text в СИМВОЛАХ (не в байтах), end не включается. Подсветку рисует клиент: API специально не отдаёт размеченный HTML, потому что эти цитаты — текст, который произнёс клиент, и он уходит в мобильное приложение, во встроенные CRM-окна и в вебхуки. У строк без совпадения ключа transcript_matches просто нет.

Счётчики звонков (`GET /calls/counters`)

GET /calls/counters возвращает бейдж-счётчики { "data": { "all": N, "missed": M } } — построены из того же самого запроса видимости, что и GET /calls (без фильтров direction/channel_id), поэтому число никогда не разойдётся со списком. Параметров нет.

Пульт супервайзера: доступ

Все эндпоинты /v1/telephony/* ниже (пульт, статистика, очередь перезвона, действия супервайзера) гейтятся ОТДЕЛЬНО от остальной группы: не проверкой telephony-фичи тарифа и telephony.use (это гейт /calls/* выше), а правом company_telephony.manage — без него 403. У вызывающего не обязателен провиженный SIP-эндпоинт (в отличие от telephony.use). На момент публикации отдельного права telephony.supervise не существует: доступ идёт через право company_telephony.manage, которым по умолчанию обладает роль admin (продуктовое решение 2026-08-13).

Пульт: активные звонки, presence, wallboard

Три read-only эндпоинта без параметров, каждый отдаёт снимок «прямо сейчас» (не пагинированы). GET /telephony/console/active-calls — { "data": { "items": [...] } }, каждый элемент: id, direction, kind, provider, status, contact (id/display_name/avatar_url либо null), channel_id, channel_name, from_number, to_number, started_at/answered_at (ISO-8601 либо null), waiting_sec (время в звонке до ответа, либо время звонка на текущий момент, если ещё звонит), talk_sec (текущая длительность разговора, null пока не отвечен), assignee ({ type: "operator"|"ai_agent"|null, id, name }).

GET /telephony/console/presence — { "data": { "items": [...] } }, по одному элементу на оператора с активным SIP-аккаунтом: user_id, name, avatar_url (фото оператора: внешняя ссылка как есть, загруженный файл — подписанной ссылкой, либо null, если фото нет), availability (сырое значение доступности пользователя), status — вычисленный статус: on_call (в разговоре), acw (после звонка, короткое окно донастройки), offline, away, free; devices ({ browser: { registered } | null, external: { registered } | null }) и metrics_today ({ answered, missed, avg_talk_sec }, в часовом поясе компании). GET /telephony/console/wallboard — плоский объект { "data": { active_calls, ringing, operators_online, operators_free, missed_today, service_level_today, service_level_threshold_sec, lines } }; operators_online/operators_free считаются из того же presence, поэтому никогда не разойдутся с ним. service_level_today — готовый процент (0–100), не доля, а service_level_threshold_sec — эхо порога сервис-левела из конфигурации (тот же, что в GET /telephony/stats), чтобы подписать KPI «в пределах N сек», не дублируя значение у себя.

wallboard.lines — состояние телефонных линий компании, то есть каналов, на которых прямо сейчас можно принимать и делать звонки (тот же признак, что calling_enabled в группе «Каналы», архивные каналы исключены). Массив, по элементу на линию: channel_id, name, provider_key (sip_telephony, whatsapp_business, whatsapp), number (config.calling_did, иначе имя аккаунта канала), state (up — линия работает, connecting — подключается, down — недоступна, unknown — состояние неизвестно), degraded (линия считается рабочей, но сигнал хуже нормы — например, SIP-транк в IP-режиме не отвечает на OPTIONS), registration_status (сырой статус регистрации SIP-транка либо null у не-SIP линий), checked_at, last_call_at (ISO-8601 либо null) и last_call_answered_by ({ id, name } — оператор, ответивший на последний звонок линии; null, если звонок был пропущен, ещё идёт или его принял ИИ-агент). Если у компании нет ни одной линии, массив пустой.

Статистика (`GET /telephony/stats`)

GET /telephony/stats — исторические KPI и ряды для графиков за период. Обязательный query-параметр period=today|7d|30d|custom; для custom дополнительно обязательны date_from/date_to (даты, date_to >= date_from, диапазон не более 92 дней — иначе 422). Периоды считаются в часовом поясе компании, затем переводятся в UTC. Ответ — плоский объект { "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 — IANA-часовой пояс компании, в котором посчитаны границы периода и ряды by_day/by_hour (подписывайте даты и часы в нём, а не в часовом поясе браузера); service_level и ai_share — готовые проценты (0–100, не доли); service_level_threshold_sec — эхо порога сервис-левела из конфигурации; missed_not_called_back (целое) — сколько пропущенных за период всё ещё открыто в очереди перезвона (тот же предикат, что у GET /telephony/callbacks при state=pending), поэтому это число никогда не больше missed; directions — { inbound, outbound }, разбивка периода по направлению (считаются все звонки направления, отвеченные и нет, поэтому inbound + outbound === total); waiting_buckets — распределение времени ожидания клиентов (см. ниже); by_day — [{ date, answered, missed }]; by_hour — [{ hour, total, answered, missed }]; by_channel — [{ channel_id, channel_name, provider, total }]; by_assignee — [{ type, id, name, answered, missed, avg_talk_sec }]. Необязательный channel_ids[] (массив целых, до 50) ограничивает все показатели номерами компании из списка; без параметра считаются все номера. Чужой или несуществующий id даёт нули (200), не ошибку.

Про by_hour: ряд всегда содержит все 24 часа (пропущенные часы заполнены нулями), час — локальный час компании. total считает звонки ОБОИХ направлений, а answered/missed — только входящие: неотвеченный исходящий звонок не является провалом обслуживания. Отсюда два следствия, которые нельзя нарушать при отрисовке: answered + missed НЕ равно total (в часе с исходящим трафиком или со звонком, который ещё звонит либо завершился ошибкой, сумма меньше) — складывайте answered/missed в стек и подписывайте столбец значением total. И sum(by_hour[].answered) не сходится с by_day[].answered (дневной ряд и KPI answered считают все направления) — сверять почасовой ряд с дневным не нужно.

Про waiting_buckets — { median_sec, buckets }, сколько клиенты ждали ответа. Считается ТОЛЬКО по отвеченным входящим звонкам, ожидание = answered_at − started_at. Ожидание на пропущенном звонке — это терпение звонившего, а не скорость ответа, поэтому две величины не смешиваются: потери отдаются отдельно (missed, by_hour[].missed, строка «никто не взял» в by_assignee), а у сорванных звонков своё поле waited_sec в очереди перезвона. buckets — всегда ровно 4 элемента { from_sec, to_sec, count }, даже за пустой период (тогда все count: 0, median_sec: null), у последнего to_sec: null (верх открыт). Границы привязаны к порогу сервис-левела SL (service_level_threshold_sec): 0–10, 10–SL, SL–2×SL, 2×SL и выше. При SL по умолчанию (20 секунд) это 0–10 / 10–20 / 20–40 / 40+, а при более жёстком пороге первая граница опускается вслед за ним (половина SL, но не больше 10 секунд), чтобы четыре интервала оставались строго возрастающими. median_sec — медиана ожидания в секундах либо null, если отвеченных входящих в периоде не было. Сумма count по всем корзинам всегда равна числу отвеченных входящих за период.

В by_assignee последней строкой всегда идёт «никто не взял»: { "type": null, "id": null, "name": null, "answered": 0, "missed": N, "avg_talk_sec": null } — пропущенные входящие за период, которые не приписаны ни одному оператору и ни одному ИИ-агенту (в том числе те, что не предлагались ни одной группе дозвона, чьи операторы получили собственные строки). Строка присутствует всегда, даже когда missed: 0, — её не нужно ни изобретать на стороне клиента, ни считать аномалией. answered в ней структурно 0 (у отвеченного звонка всегда есть исполнитель), а avg_talk_sec — null, а не 0: никто не разговаривал, это не то же самое, что «разговаривал ноль секунд». Оговорка про сумму: звонок, который одновременно звонил двум операторам с собственными строками, засчитан как пропущенный каждому из них, поэтому сумма missed по by_assignee может превышать KPI missed на величину такого пересечения.

Очередь перезвона (`/telephony/callbacks`)

GET /telephony/callbacks (с 2026-09-10) — список пропущенных входящих звонков как задач «кому перезвонить». Очередь не хранится отдельной таблицей, а выводится из журнала звонков, поэтому её видимость совпадает с GET /calls: скрытые из рабочего inbox контакты не попадают ни туда, ни сюда. Строки сгруппированы по собеседнику (сначала по contact_id, а для звонка без контакта — по точному совпадению номера целиком; анонимные звонки не объединяются вовсе), поэтому три пропущенных звонка от одного человека — это одна строка с attempts: 3, а не три задачи.

Параметры: period=today|7d|30d (по умолчанию 7d — пропущенный в пятницу вечером звонок должен пережить выходные), state=pending|resolved|all (по умолчанию pending), page (1–1000) и perPage (1–100, по умолчанию 50); значения вне диапазона — 422. Ответ: { "data": { items, pagination, counters, timezone } }, где pagination — { page, perPage, total, lastPage }, counters — { pending, resolved_today } для бейджа, а timezone — часовой пояс компании, в котором посчитаны периоды и resolved_today. Элемент items[]: call_id, contact ({ id, display_name, avatar_url } либо null), from_number, channel_id, channel_name, conversation_id, missed_at, waited_sec (сколько звонивший ждал ответа до срыва — это не то же самое, что скорость ответа на отвеченном звонке, поэтому две величины никогда не усредняют вместе), attempts (не меньше 1), outside_working_hours (звонок пришёл вне рабочего расписания компании) и resolution — null для открытой задачи, иначе { type, at, by_user, call_id, duration_sec }.

POST /telephony/callbacks/{call}/resolve снимает задачу вручную: тело { "resolution": "called_back" | "dismissed" } (обязательное, другое значение — 422). Успех (200): { "data": { "call_id": N, "resolution": { ... } } }. Вручную закрывать очередь обычно не требуется: когда исходящий звонок тому же собеседнику ОТВЕЧЕН, все его открытые пропущенные закрываются автоматически, в окне 30 дней назад; неудавшийся дозвон очередь не трогает. У автоматического закрытия тот же resolution.type: "called_back", что и у ручного, но заполнены resolution.call_id и resolution.duration_sec — это тот самый исходящий звонок, который закрыл задачу (у ручного закрытия они null). Лимит resolve — 60 запросов в минуту на пользователя, при превышении 429.

  • 403 — у вызывающего нет права company_telephony.manage (то же право, что и у остального пульта), либо запрос идёт embed-токеном: оба эндпоинта очереди закрыты для токенов встраиваемых виджетов, потому что очередь — это список имён и телефонов контактов.
  • 404 (только resolve) — звонок не существует, принадлежит другой компании или не виден вызывающему. Проверка права идёт ДО поиска звонка, поэтому по коду ответа нельзя перебирать чужие id звонков.
  • 409 (только resolve) — звонок не является пропущенным входящим либо задача уже закрыта (в том числе автоматически другим оператором); повторный вызов не «переоткрывает» и не переписывает закрытие.

Живое обновление: при каждом изменении очереди (звонок финализирован как пропущенный, задача закрыта вручную или автоматически) на company-канал уходит WebSocket-событие telephony.callback.updated с payload { callId, pending } — этого достаточно, чтобы перерисовать бейдж и сбросить кэш списка, сам список приходит только из REST.

Действия супервайзера (`listen`/`whisper`/`barge`/`transfer`)

Четыре POST /telephony/console/calls/{call}/{action} для живого звонка: listen — тихое прослушивание, whisper — суфлирование оператору (клиент не слышит), barge — включение в разговор (слышно обеим сторонам), transfer — перевод другому оператору. Все 4 действия требуют, чтобы звонок был именно в статусе answered — иначе 422 (изначально transfer допускал ещё звонящий ringing звонок, но такой перевод не поддержан ни бриджем, ни UI пульта, поэтому в ревью это было выровнено с остальными тремя действиями). transfer принимает обязательное тело { "target_user_id": <int> } — получатель должен быть реальным (не сервис-аккаунтом) участником той же компании И иметь там активный SIP-аккаунт оператора, иначе 422. Успех (200): { "data": { "message": "Supervisor action executed.", "action": "<listen|whisper|barge|transfer>" } }; следом асинхронно уходит вебхук call.supervised. Лимит — 30 запросов в минуту на пользователя (лимитер telephony-supervise), при превышении 429.

  • 404 — звонок не найден или принадлежит другой компании.
  • 403 — вызывающий это сервис-аккаунт (PAT), ЛИБО у него нет права company_telephony.manage (проверяется в этом порядке).
  • 422 — звонок неактивен (уже завершён либо статус не ringing/answered), либо ещё не отвечен (answered требуется для ВСЕХ 4 действий, включая transfer), либо у звонка пуст/отсутствует asterisk_channel_id, либо (только transfer) не передан target_user_id, либо получатель не является активным SIP-оператором именно этой компании, либо бридж отклонил действие по собственной причине — тело ответа отдаёт фиксированное сообщение, сырая ошибка бриджа в него не попадает (логируется на сервере).
  • 409 — звонок уже супервайзится другим пользователем (гард на стороне бриджа).
  • 410 — живой канал звонка пропал с бриджа между проверкой и вызовом (звонок завершился в этот момент).
  • 502 — бридж (asterisk-bridge) недоступен или вернул ошибку ARI.

Исходящие схемы звонков (волна 1)

GET /telephony/schemes возвращает схемы маршрутизации звонков компании и принимает необязательный direction=inbound|outbound (без параметра — все схемы). Элемент списка: id, direction (inbound|outbound), name, comment, status (draft|active), timezone, outbound_priority (целое, по умолчанию 100 — тай-брейк между КОМАНДАМИ, указывающими на разные исходящие схемы: меньшее значение побеждает; для входящей схемы поле не имеет смысла), channels_count, assigned_operators_count, assigned_teams_count (сколько операторов/команд привязано к схеме — вход исходящей схемы; для входящей всегда 0), outbound_line (только у исходящей схемы, и только если её первый узел dial_external называет конкретную линию: { "channel_id", "name", "number" }, иначе null), node_count, updated_at. Требует права company_telephony.manage, лимитер telephony-schemes (120 запросов в минуту на пользователя) — тот же, что у остального редактора схем.

Направление задаётся при создании схемы (POST /telephony/schemes, тело содержит direction) и не меняется впоследствии — входящая и исходящая схемы используют непересекающиеся наборы узлов графа. Основная схема сотрудника выбирает линию ПО УМОЛЧАНИЮ; может ли он вручную выбрать другую, решает политика компании «выбор номера перед звонком» (см. раздел «Выбор номера: три ступени» ниже). Компания без исходящей схемы работает как до этой фичи: GET /telephony/outbound-line вернёт "scheme": null и прежний список кандидатов (первый подходящий канал компании).

GET /telephony/outbound-line — превью того, на какую линию уйдёт следующий звонок оператора: используется тот же резолвер (OutboundLineResolver), что и постановка самого звонка, поэтому подсказка набора номера не может разойтись с реальным звонком. Параметры: to_number (телефон, ^\+?\d{3,20}$) или contact_id — один из двух обязателен, иначе 422. Гейт — право telephony.use (это чтение о СВОЁМ следующем звонке оператора, а не административное действие), отдельный лимитер telephony-outbound-line — 60 запросов в минуту на пользователя (эндпоинт опрашивается на каждое нажатие клавиши в наборе номера). Полная форма ответа: { "scheme": { "id", "name" } | null, "blocked", "block_reason", "candidates": [ { "channel_id", "name", "dial_mode", "is_default" } ], "rejected": [ { "channel_id", "reason" } ] }. candidates упорядочен, линия с is_default: true (всегда первая) предзаполняется в наборе номера; rejected объясняет, почему остальные линии компании отсеяны — channel_not_connected, provider_ip_gated, либо код того же WhatsApp-разрешения на звонок, что и GET /contacts/{contact}/outbound-eligibility (группа «Контакты»). blocked: true означает, что сама схема запрещает этот звонок (ветка hangup в графе) — тогда block_reason несёт текст, заданный при настройке схемы, а candidates пуст.

В ответе GET /telephony/outbound-line (и в полном, и в урезанном виде для embed) есть поле allowed_channel_ids — массив id линий (int[]), которые оператору разрешено выбирать вручную по его исходящей схеме, либо null, если у сотрудника нет исходящей схемы и допустима любая линия. По этому полю интерфейс сужает список «Позвонить через».

Ручной выбор линии подчиняется схеме: если в POST /calls/originate или POST /softphone/external-call передан channel_id, которого нет в схеме оператора, ответ — 422 { "data": { "message": "This line is not allowed by your outbound route.", "error_code": "line_not_in_scheme" } }. Проверка идёт после проверки доступа к каналу (404). Без исходящей схемы ограничения нет.

Выбор номера: три ступени

С 2026-10-20 номер для исходящего звонка выбирается в три ступени. Ступень 1 — ОСНОВНАЯ схема сотрудника: из набора её линий берётся номер по умолчанию, а резервы, нарисованные внутри самой схемы (карусель, цепочка «не дозвонились, пробуем следующий»), работают как раньше. Ступень 2 — общий номер компании (default_channel_id в /companies/current/telephony/outbound-settings) для сотрудников без схем. Ступень 3 — ручной выбор номера перед звонком (channel_id), он зависит от переключателя manual_pick_enabled. Сотрудник может состоять в нескольких исходящих схемах; основную назначает админ (PUT /companies/current/members/{memberId}/telephony/outbound-primary, группа «Пользователи и команды»), а если явного выбора нет, действует прежний порядок: личная схема раньше командной, затем outbound_priority, затем время создания. Звонок НИКОГДА не переходит в другую схему и не подставляет общий номер сам: если основная схема не смогла дозвониться, резервы внутри неё отработали, а дальше звонок не уходит.

Переключатель manual_pick_enabled (по умолчанию true, то есть как до обновления) относится ко ВСЕМ поверхностям, включая API для интеграторов. Если компания его выключила, то channel_id, которого нет среди линий основной схемы сотрудника, отклоняется: POST /calls/originate и POST /softphone/external-call отвечают 422 { "data": { "message": "...", "error_code": "manual_pick_disabled" } }. channel_id, совпадающий с линией основной схемы, проходит — клиенты, которые всегда шлют номер по умолчанию, не ломаются. Это ломающее изменение для интеграторов, которые передают произвольный channel_id: прежде чем компания выключит переключатель, убедитесь, что ваш клиент либо не шлёт channel_id (линию выберет сервер), либо берёт его из GET /telephony/outbound-line (lines[].is_default или primary_scheme). Компании, которые ничего не настраивали, не затронуты: переключатель включён, схем нет, поведение прежнее. Если у сотрудника нет схем и общий номер не задан, переключатель ничего не ограничивает; то же при основной схеме с шагом «любая доступная линия». Если channel_id не передан, а основная схема сотрудника или общий номер недоступны, звонок не уходит: POST /calls/originate отвечает 422 с error_code: "scheme_blocked" и block_reason: "company_default_unavailable" (общий номер отключён, удалён или не может звонить), а превью GET /telephony/outbound-line — blocked: true с тем же block_reason.

Новые и расширенные поля. GET /telephony/outbound-line: manual_pick_enabled (boolean; единственное новое поле и в урезанном виде для embed — по нему интерфейс решает, показывать ли выбор номера), только в полной форме: primary_scheme ({ "id", "name" } или null) и candidates[].source (primary — линия основной схемы, company_default — общий номер, legacy — прежний выбор «любая доступная»). В блоке линий карточки контакта (contact_id) каждая линия получает source (primary | scheme | company_default | legacy) и scheme_name (название схемы или null); для embed-токена scheme_name не отдаётся никогда, source остаётся. Без contact_id (звонилка) ответ дополнительно содержит selectable_lines — линии, которые сотрудник может выбрать вручную: [ { "channel_id", "name", "kind", "from_number", "source", "scheme_name" } ], сначала основная и общий номер, затем линии других его схем; при выключенном manual_pick_enabled — только линия основной схемы. Embed softphone-токен получает этот список без from_number и scheme_name, chat-embed — без scheme_name. GET /telephony/schemes: у исходящих схем добавлены outbound_channel_ids (все линии, на которые схема может набирать, int[]) и assignees ({ "operators": [ { "id", "name" } ], "teams": [ { "id", "name" } ] }, до 5 операторов и до 5 команд — это превью, не полный список; полный даёт GET .../assignments); у входящих схем оба поля null. Эндпоинты, перечисленные выше, гейтятся company_telephony.manage и недоступны embed-токенам, кроме GET /telephony/outbound-line (его урезанный вид для embed описан выше); GET /telephony/schemes для embed-токена теперь отвечает 403, потому что в списке появились имена сотрудников и команд.

GET /companies/current/telephony/outbound-settings и PATCH по тому же пути — политика компании. Ответ: { "data": { "default_channel_id": int|null, "default_channel": { "id", "name", "number" } | null, "default_channel_available": boolean|null, "manual_pick_enabled": boolean } }; default_channel_available равен null, пока общий номер не задан, и false, когда он задан, но недоступен (тогда звонки сотрудников без схем уходят в отказ company_default_unavailable, пока номер не заменят). PATCH принимает любое подмножество default_channel_id (id линии своей компании или null, чтобы снять) и manual_pick_enabled (boolean) и возвращает тот же ответ. Линия, которая не принадлежит компании, удалена, в архиве или не умеет звонить, отклоняется одинаково: 422 { "data": { "error_code": "default_channel_invalid" } }. Гейт — company_telephony.manage; PATCH ещё требует полноправный токен (не ограниченный по правам); embed-токены получают 403. Лимитер telephony-outbound-settings. Каждое изменение пишет аудит telephony.outbound_settings.updated и отправляет в WebSocket событие telephony.outbound.updated (см. руководство по событиям реального времени).

dial_mode у каждого кандидата — dial_direct (SIP-транк или серый/личный WhatsApp: набирается всегда, без окна разрешения и без бюджета Meta) либо dial_window_gated (WhatsApp Business Calling: нужно окно разрешения от абонента и неисчерпанный 24-часовой бюджет звонков). В волне 1 из софтфона (клавиатуры набора) доступны только линии dial_direct.

Для embed-поверхностей (виджет встраиваемого софтфона и чат-iframe партнёрских панелей — токен со способностью embed:softphone:* либо embed:read) ответ ЖЁСТКО урезан по требованию ИБ-ревью 2026-09-13: вместо scheme/candidates/rejected отдаются ровно 4 поля — { "blocked", "block_reason", "default_channel_id", "dial_mode" }, где default_channel_id/dial_mode относятся к тому же кандидату, что candidates[0] в полной форме. Полный перечень линий компании (их DID-номера), название схемы и причины отказа других линий — это инвентарь телефонии компании, который встроенная в чужой origin поверхность видеть не должна; урезание не зависит от того, звонок это или превью.

Кто звонит через исходящую схему — GET/PUT /telephony/schemes/{scheme}/assignments, зеркало привязки каналов к входящей схеме. Гейт — company_telephony.manage, тот же лимитер telephony-schemes. {scheme} — uuid, не скопированный по компании при route-model binding, поэтому оба метода ПЕРВЫМ делом проверяют scheme.company_id и отвечают 404 { "data": { "message": "Scheme not found." } } на чужую или несуществующую схему — раньше проверки тела запроса. GET отвечает { "data": { "assignments": { "operators": [ { "id", "name" } ], "teams": [ { "id", "name" } ], "conflicts": [ { "user_id", "via_team_id", "personal_scheme_id" } ] } } }; мягко удалённые операторы/команды в список не попадают. conflicts — устаревшее поле, с 2026-10-20 всегда пустой массив [] (оставлено ради совместимости): сотрудник может состоять в нескольких исходящих схемах, личное назначение больше не «перебивает» командное.

PUT /telephony/schemes/{scheme}/assignments принимает ПОЛНЫЙ желаемый набор, не дельту: { "operator_ids": [...], "team_ids": [...] } (оба поля обязательны как массивы, пустой массив — законный способ сказать «никто»; лимиты — 500 операторов и 200 команд). Всё, что не перечислено, отвязывается. И операторы, и команды должны реально состоять в компании (роль в model_has_roles для операторов, teams.company_id для команд) — иначе 422 со списком чужих id в errors.operator_ids/errors.team_ids. С 2026-10-20 запрос НИКОГО не переносит: оператор или команда могут состоять в нескольких исходящих схемах сразу (раньше привязка к новой схеме молча снимала её с прежней), поэтому привязка к другой схеме ничего не отнимает у остальных. Поля moved и conflicts в ответе остаются ради совместимости, но всегда пусты: { "data": { "assignments": {...}, "moved": { "operators": [], "teams": [] } } }. Если у сотрудника убрали привязку к его основной схеме, указатель основной схемы сбрасывается сам. 422 { "data": { "errors": { "scheme": [...] } } } — попытка назначить операторов/команды входящей схеме (direction ≠ outbound). 409 — гонка двух одновременных сохранений одного и того же набора (уникальный индекс поймал коллизию первым); нужно повторить запрос. Каждое успешное сохранение пишет запись аудита telephony.outbound_scheme.assigned.

Схемы звонков: создание, граф, привязка

Ниже: всё, что нужно, чтобы собрать маршрутизацию входящих (и исходящих) звонков через API, без кабинета. Доступны только Business-компаниям (restrict.company.type:business) с активной подпиской; каждый вызов требует права company_telephony.manage в текущей компании. Работает cookie-сессия и Bearer-токен компании, в том числе api_token сервисного аккаунта партнёра; embed-токены и токены connect-сессии не допускаются. Лимитер telephony-schemes (120 запросов в минуту на пользователя) общий для всего редактора. Чужая или несуществующая схема отвечает 404 { "data": { "message": "Scheme not found." } }. Ошибки валидации приходят как 422 { "data": { "message", "errors" } }.

Схема. POST /telephony/schemes принимает name (обязателен, до 120 символов), direction (inbound по умолчанию | outbound; после создания НЕ меняется, попытка передать direction в PATCH даёт 422 — создайте новую схему), timezone (идентификатор IANA, например Asia/Almaty; по умолчанию UTC) и comment (до 1000 символов). Ответ 201 { "data": { "scheme": {...} } }: схема создаётся в статусе draft с единственным узлом entry (для исходящей: outbound_entry). Черновик не обрабатывает звонки. GET /telephony/schemes/{scheme} возвращает ту же форму: id, direction, name, comment, timezone, status, outbound_priority, moh_as_ringback, moh_prompt_id, channels[] (id, name), nodes[] (id, type, position {x,y}, data) и edges[] (id, source, target, sourceHandle, label, data).

PATCH /telephony/schemes/{scheme} принимает частичное тело: name, comment, timezone, status (active включает схему, draft выключает), outbound_priority (0..1000, имеет смысл только у исходящей), moh_as_ringback (bool) и moh_prompt_id (id готового аудио-приглашения своей компании или null). Ответ тот же { "data": { "scheme": {...} } }. Активация исходящей схемы, чей вход никуда не ведёт, отклоняется: 422, errors.status: ["route_leads_nowhere"]. DELETE /telephony/schemes/{scheme} отвечает { "data": { "message": "Scheme deleted." } }.

Граф схемы: PATCH /telephony/schemes/{scheme}/builder

PATCH /telephony/schemes/{scheme}/builder ПОЛНОСТЬЮ заменяет граф схемы: старые узлы и рёбра удаляются, записываются присланные (не дельта; сначала прочитайте GET, правьте и отправляйте целиком). Тело: nodes[] (обязателен, минимум 1; каждый: id строка до 255 символов, type, необязательные position {x,y} и data-объект) и edges[] (source, target: клиентские id узлов; sourceHandle; необязательные id, label, data). Клиентские id узлов связывают рёбра внутри запроса; id существующего узла этой схемы (uuid из GET) сохраняется, остальные получают новые uuid от сервера (итоговые id вернёт ответ — { "data": { "scheme": {...} } }), а ребро с неизвестным source/target молча отбрасывается. Подробности про сохранение узлов и потолки размера — в следующем абзаце. Ровно один входной узел: entry у входящей схемы, outbound_entry у исходящей, иначе 422 (errors.nodes).

Сохранение графа сохраняет идентификаторы узлов: узел, чей id совпадает с узлом этой же схемы, обновляется на месте, а не пересоздаётся, поэтому звонки, которые прямо сейчас стоят в меню, очереди или на звонке группе, не обрываются при правке схемы. Чужой, неизвестный или не-uuid id создаёт новый узел. Узлы, которых нет в теле запроса, удаляются; рёбра всегда записываются заново. Потолки размера графа (превышение даёт 422): не больше 200 узлов, 400 рёбер; data одного узла до 16 КБ, data ребра до 2 КБ, весь граф (узлы и рёбра) до 256 КБ. data.timeout_sec узлов ring_group и responsible_manager должен быть целым от 5 до 600, иначе 422 на nodes.N.data.timeout_sec.

Типы узлов зависят от направления; чужой тип даёт 422 на nodes.N.type. Входящая схема: entry, schedule, ring_group, hangup, client_check, play, voicemail, responsible_manager, ivr_menu, queue, ai_agent. Исходящая: outbound_entry, schedule, dial_external, hangup. sourceHandle ребра — одно из out, work, off, no_answer, known, new, no_input, timeout, digit_0..digit_9, escalated, failed; какие из них у узла бывают — в списке ниже. Ключи data не проверяются схемой запроса (узел с неизвестными ключами сохранится как есть), поэтому пользуйтесь только перечисленными.

  • entry: без data; единственная ветка out к первому шагу.
  • play: data.prompt_id (id готового аудио-приглашения); ветка out. Приглашение не задано или ещё не готово: узел пропускается, звонок идёт по out.
  • ivr_menu: data.prompt_id (меню), data.digits[] (какие цифры принимаются, строками, например ["1","2"]), data.timeout_sec (ожидание нажатия, 2..30, по умолчанию 5), data.repeats (сколько раз проиграть меню, 1..5, по умолчанию 2). Ветки: digit_N для каждой принятой цифры и no_input (тишина, неверная цифра после исчерпания повторов, нет готового приглашения). Принятая цифра без ребра digit_N обрывает звонок.
  • schedule: либо data.intervals[] из { "weekday": 1..7, "start": "09:00", "end": "18:00" } (weekday по ISO, 1 = понедельник; время HH:MM в часовом поясе СХЕМЫ; end не позже start означает переход через полночь, но засчитывается только вечерняя часть интервала), либо data.use_company_hours: true (рабочие часы компании, те же, что у автоответа чата; компания без расписания считается всегда открытой). Ветки work и off; обе должны быть соединены, иначе звонок обрывается. Работает и в исходящей схеме.
  • ring_group: data.members = { "operators": [user_id], "teams": [team_id] } (пусто — вся компания), data.strategy (all — звонить всем сразу, по умолчанию; sequential — по очереди; priority — группами из data.priority_groups[], каждая { "users": [user_id] }), data.timeout_sec (сколько звонить на шаге, 5..600, по умолчанию 25), data.record (bool, по умолчанию true; запись реально включается только если запись разговоров включена в компании), data.record_stereo (bool), data.ringback (standard | scheme_moh | id приглашения). Ветка no_answer (никто не ответил или некому звонить). Если в data.members отмечены команды или операторы, но их не осталось (команда удалена, пуста или чужая), узел не звонит всей компании, а сразу уходит в no_answer; пустой выбор (ни команд, ни операторов) звонит всей компании.
  • voicemail: data.greeting_id (приглашение-приветствие, необязательно), data.max_sec (длина сообщения, 10..600, по умолчанию 120); ветка out после записи. Голосовая почта не считается записью разговора и не отключается настройкой записи.
  • hangup: data.reason (строка-причина для журнала, по умолчанию scheme_hangup); без веток.
  • Остальные узлы: client_check (ветки known/new: звонящий найден среди контактов или нет), responsible_manager (звонок ответственному за контакт; ветка no_answer), queue (очередь с ожиданием; ветка timeout), ai_agent (голосовой ИИ-сотрудник; ветки escalated/failed; доступен не всем компаниям); исходящие outbound_entry и dial_external (линия или карусель линий: data.channel_id / data.carousel.channel_ids[], до 20, линии должны быть вашей компании и уметь звонить). Подробные ключи этих узлов здесь не фиксируются.

Мёртвая ветка — нормальная ошибка маршрута, а не ошибка запроса: PATCH .../builder её не проверяет, но во время звонка узел без нужного ребра (например schedule без off) завершает вызов. Поэтому соединяйте все ветки узла. Для узла ai_agent с включённой эскалацией ребро escalated обязательно уже при сохранении.

Привязка каналов к входящей схеме

Входящая схема срабатывает на звонки по тем каналам, к которым она привязана, и только пока status равен active. GET /telephony/scheme-channels возвращает { "data": { "channels": [...] } }: каналы компании, способные принимать звонки (SIP, WABA с включёнными звонками, личный WhatsApp в режиме приёма звонков); элемент: id, name, type, calling_did, country, status, has_scheme, scheme_id, scheme_name, scheme_status. POST /telephony/schemes/{scheme}/channels принимает { "channel_ids": [int] } — ПОЛНЫЙ набор каналов схемы (поле обязательно, пустой массив отвязывает все; каналы вне набора отвязываются); все каналы должны быть вашей компании, иначе 422 (errors.channel_ids). POST /telephony/channels/{channel}/scheme принимает { "scheme_id": "uuid" | null } (поле обязательно; null отвязывает) и привязывает ОДИН канал; чужой канал — 404 Channel not found.. Исходящие схемы к каналам не привязываются (422, Outbound schemes are not bound to channels.): они включаются через назначение операторов (PUT /telephony/schemes/{scheme}/assignments). Оба POST отвечают { "data": { "message": "..." } }.

Аудио-приглашения

Приветствия, меню IVR и музыка ожидания берутся из библиотеки приглашений компании. GET /telephony/audio-prompts возвращает { "data": { "items": [ { "id", "name", "status", "original_filename", "created_at" } ] } }. POST /telephony/audio-prompts — multipart/form-data с полями name (до 120 символов) и file (mp3, wav, ogg, oga, m4a, aac, webm или mp4, до 15 МБ). Файл перекодируется под телефонию и доставляется на телефонный сервер синхронно: успех — 201 { "data": { "item": {...} } } со status: "ready"; если перекодировать или доставить не удалось — 422 с message. Проигрывается только приглашение в статусе ready; узлы play/ivr_menu/voicemail со ссылкой на неготовое или чужое приглашение пропускаются. DELETE /telephony/audio-prompts/{prompt} — { "data": { "message": "Prompt deleted." } } (чужое — 404). Идентификатор из id и подставляется в prompt_id, greeting_id, moh_prompt_id.

Команды для ring_group (members.teams) создаются через /v1/teams — см. группу «Команды и пользователи».

Настройки записи разговоров и срок хранения

Три эндпоинта подраздела «Что записываем» (Телефония → Настройки): GET и PATCH /v1/companies/current/telephony/recording-settings, плюс GET /v1/companies/current/telephony/recording-settings/retention-preview. Компания берётся из current_company_id вызывающего — идентификатора компании в пути нет вовсе, поэтому адресовать чужую компанию нечем. Гейт — существующее право company_telephony.manage (у роли admin любой компании оно есть всегда); нового права фича не вводит. Запрос с embed-токеном (способность embed:softphone:* или embed:read) отклоняется сразу, до всякой проверки тела: 403 { "data": { "message": "This action is unauthorized." } }. Лимитер telephony-recording-settings — 30 запросов в минуту на пользователя на все три эндпоинта, при превышении 429. GET и PATCH работают и при неактивной подписке (это выключатель, которым арендатор ОСТАНАВЛИВАЕТ запись и удаление, — терять его вместе с подпиской нельзя); retention-preview требует активной подписки, потому что это лишь вспомогательный расчёт.

GET .../recording-settings отвечает { "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 } } }. retention_months равное null — это значение, а не пропуск: «не удалять», и это умолчание для всех компаний, существующих и новых; удаление по сроку включается только явным выбором. retention_options — белый список сроков в месяцах, который одновременно предлагает интерфейс и принимает PATCH. retention_effective_at — когда ночная уборка ВПЕРВЫЕ применит текущий срок (конец недели остывания, а не ближайшая ночь); null, пока удалять нечего. retention_paused_reason объясняет, почему при выбранном сроке ничего не удаляется: cooling_off, subscription_inactive, company_blocked, company_deleted, invalid_term, missing_change_timestamp, company_missing — либо null, если уборка отработает на следующем прогоне. archive — снимок ночного замера, а не счёт на лету: пока measured_at равен null, объём показывать нечего. Строка настроек создаётся лениво при первом же GET, но ответ всегда 200 (не 201), чтобы форма контракта не зависела от истории компании.

PATCH .../recording-settings принимает частичное тело — трогаются только присланные поля, поэтому клиент, знающий об одном переключателе, не может обнулить настройку, о которой не слышал. Поля: record_calls_enabled (bool), retention_months (null либо одно из значений retention_options; произвольное число — 422 с обычной ошибкой валидации), плюс два флага подтверждения confirm_retention_shortening и acknowledge_recording_notice (bool). Успешный ответ — та же форма, что у GET.

Два условных отказа PATCH отвечают ПЛОСКИМ телом { "message": ..., "error_code": ... } со статусом 422 — без обёртки data, в отличие от обычной ошибки валидации ({ "data": { "message", "errors" } }): фронт различает их по машинному коду и открывает нужный диалог.

  • recording_notice_acknowledgement_required — переход record_calls_enabled из false в true без acknowledge_recording_notice: true. Платформа не проигрывает автоматического предупреждения о записи: приветствие маршрута — это загруженный компанией аудиофайл, а у исходящих звонков приветствия нет вовсе. Поэтому включение записи требует признать, что уведомить абонента — обязанность компании-клиента; признание пишется в журнал действий вместе с версией текста. Выключение записи подтверждения не требует.
  • retention_shortening_requires_confirmation — сокращение срока без confirm_retention_shortening: true. Сокращением считается переход к более короткому сроку (12 → 3) И переход из «не удалять» в любой конкретный срок (null → 12) — последнее и есть самое большое удаление, которое эта настройка может запланировать. Удлинение (3 → 12), возврат к «не удалять» (12 → null) и повторный выбор того же срока подтверждения не требуют, поэтому повторная отправка той же формы идемпотентна.

Побочные эффекты успешного PATCH: любая смена срока (в том числе удлинение) перезапускает неделю остывания — retention_changed_at обновляется, и до конца окна ночная уборка компанию не трогает; включение и выключение записи, а также включение и сокращение срока рассылают уведомление в кабинете ВСЕМ администраторам компании (единственный компенсирующий контроль против угнанной сессии — отдельного права на уничтожение архива фича не вводит); каждое изменение пишет запись аудита (telephony.recording_settings.updated, при включении записи ещё и telephony.recording.consent_acknowledged) и запоминается на строке настроек как last_changed_by/last_changed_at. Удаление по сроку необратимо: ночной прогон удаляет файл записи, кэшированный m4a-дериват, вложение в карточке звонка и стирает расшифровку и сводку связанного голосового звонка, оставляя звонку recording_status: "expired". Прогон ограничен потолком записей на компанию за ночь, поэтому большой архив уменьшается за несколько ночей, а не одним махом.

GET .../recording-settings/retention-preview?months=3 — что именно уничтожит выбранный срок. months обязателен и должен быть из того же белого списка (3, 6, 12), иначе 422: «превью по умолчанию» не существует, потому что умолчание (null, не удалять) не удаляет ничего. Ответ: { "data": { "months": int, "affected_calls": int, "affected_bytes": int, "unmeasured_calls": int, "scrubbed_transcripts": int, "oldest_started_at": iso|null, "first_deletion_at": iso } }. Считается тем же предикатом, что и сама ночная уборка, поэтому диалог описывает именно то удаление, которое санкционирует. unmeasured_calls больше нуля означает, что affected_bytes ЗАНИЖЕН (у части записей размер ещё не измерен) — в таком случае показывают количество записей, а не объём. scrubbed_transcripts — расшифровки, которые будут стёрты вместе с записями; расшифровка звонка, у которого записи не сохранилось, под срок хранения не попадает никогда, поэтому у компании с выключенной записью это число всегда ноль.

Эндпоинты

МетодПуть
GET/v1/calls

Журнал звонков компании (пагинация, фильтры all/missed/direction/channel_id/q/assignee_type; архив: has_recording/assigned_user_id/date_from-date_to/with_summary/lazy_recordings).

GET/v1/calls/counters

Счётчики звонков `{all, missed}` — та же видимость, что и журнал.

POST/v1/calls/originate

Инициировать исходящий звонок контакту через WhatsApp- или SIP-канал; `channel_id` необязателен — без него линию выбирает исходящий маршрут оператора.

POST/v1/calls/{call}/hangup

Завершить активный звонок (доступно участнику вызова или менеджеру телефонии).

GET/v1/calls/{call}/transcript

Расшифровка и сводка голосового ИИ-звонка (участнику звонка, менеджеру телефонии или читателю диалога ИИ-звонка).

GET/v1/calls/{call}/recording-url

Подписанная ссылка на запись звонка на 15 минут (тот же гейт, что у transcript).

GET/v1/telephony/console/active-calls

Пульт супервайзера: список активных звонков компании прямо сейчас.

GET/v1/telephony/console/presence

Пульт супервайзера: присутствие/статус операторов телефонии.

GET/v1/telephony/console/wallboard

Пульт супервайзера: сводные KPI «прямо сейчас» (wallboard).

GET/v1/telephony/stats

Историческая статистика звонков за период (today/7d/30d/custom) + ряды для графиков.

GET/v1/telephony/callbacks

Очередь перезвона: пропущенные входящие, сгруппированные по собеседнику (period/state/page/perPage) + счётчики.

POST/v1/telephony/callbacks/{call}/resolve

Снять задачу из очереди перезвона: `{ "resolution": "called_back" | "dismissed" }`.

POST/v1/telephony/console/calls/{call}/listen

Супервайзер: тихо прослушать отвеченный звонок.

POST/v1/telephony/console/calls/{call}/whisper

Супервайзер: суфлировать оператору на отвеченном звонке (клиент не слышит).

POST/v1/telephony/console/calls/{call}/barge

Супервайзер: включиться в отвеченный звонок (слышно обеим сторонам).

POST/v1/telephony/console/calls/{call}/transfer

Супервайзер: перевести звонок другому оператору компании (target_user_id).

GET/v1/telephony/schemes

Список схем маршрутизации звонков (входящих/исходящих); опциональный фильтр `direction=inbound|outbound`.

GET/v1/telephony/outbound-line

Превью линии для следующего исходящего звонка оператора (`to_number` или `contact_id`) — тот же резолвер, что применит сам звонок.

GET/v1/telephony/schemes/{scheme}/assignments

Кто (операторы/команды) назначен на исходящую схему; поле `conflicts` с 2026-10-20 всегда пустое.

PUT/v1/telephony/schemes/{scheme}/assignments

Задать полный набор операторов/команд исходящей схемы (не дельта) — отсутствующие в списке отвязываются; других схем запрос не затрагивает (`moved` всегда пуст).

GET/v1/companies/current/telephony/outbound-settings

Политика исходящих компании: общий номер (`default_channel_id`) и переключатель «выбор номера перед звонком» (`manual_pick_enabled`).

PATCH/v1/companies/current/telephony/outbound-settings

Изменить общий номер и/или переключатель выбора номера (любое подмножество полей); чужая или неработающая линия — `422 default_channel_invalid`.

POST/v1/telephony/schemes

Создать схему (`name`, `direction` inbound|outbound, `timezone`, `comment`): черновик с одним входным узлом.

GET/v1/telephony/schemes/{scheme}

Схема целиком (builder): настройки, привязанные каналы, узлы и рёбра.

PATCH/v1/telephony/schemes/{scheme}

Изменить настройки схемы: `name`, `comment`, `timezone`, `status` (`active` включает), `outbound_priority`, `moh_as_ringback`, `moh_prompt_id`. `direction` не меняется.

DELETE/v1/telephony/schemes/{scheme}

Удалить схему.

PATCH/v1/telephony/schemes/{scheme}/builder

Полностью заменить граф схемы: `nodes[]` и `edges[]` (ровно один входной узел, типы узлов по направлению).

POST/v1/telephony/schemes/{scheme}/channels

Задать полный набор каналов входящей схемы (`channel_ids[]`); отсутствующие отвязываются.

POST/v1/telephony/channels/{channel}/scheme

Привязать один канал к входящей схеме (`scheme_id`) или отвязать (`null`).

GET/v1/telephony/scheme-channels

Каналы компании, способные принимать звонки, с их текущей схемой.

GET/v1/telephony/audio-prompts

Библиотека аудио-приглашений компании (`id`, `name`, `status`).

POST/v1/telephony/audio-prompts

Загрузить приглашение (`multipart`: `name`, `file` до 15 МБ); играется в статусе `ready`.

DELETE/v1/telephony/audio-prompts/{prompt}

Удалить аудио-приглашение.

GET/v1/companies/current/telephony/recording-settings

Настройки записи разговоров компании: включена ли запись, срок хранения (`null` = не удалять), причина паузы уборки и снимок объёма архива.

PATCH/v1/companies/current/telephony/recording-settings

Изменить настройки записи (частичное тело): переключатель записи и срок хранения; включение записи и сокращение срока требуют флага подтверждения.

GET/v1/companies/current/telephony/recording-settings/retention-preview

Сколько записей, байт и расшифровок уничтожит выбранный срок (`months` из белого списка) и когда произойдёт первое удаление.

Примеры

Исходящий WhatsApp-звонок

Запрос

bash
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
  }'

Ответ

json
{
  "data": {
    "call": {
      "id": 5001,
      "direction": "outbound",
      "status": "ringing",
      "to_number": "7700000XXXX",
      "from_number": "7708550XXXX",
      "asterisk_channel_id": "1720787000.4521",
      "timeline_message_id": 88214
    }
  }
}

Исходящий звонок через SIP-транк

Запрос

bash
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
  }'

Ответ

json
{
  "data": {
    "call": {
      "id": 5002,
      "direction": "outbound",
      "status": "ringing",
      "to_number": "7700000XXXX",
      "from_number": "7727000XXXX",
      "asterisk_channel_id": "1720787100.4530",
      "timeline_message_id": 88221
    }
  }
}

Звонок без channel_id — линию выбирает исходящий маршрут

Запрос

bash
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
  }'

Ответ

json
{
  "data": {
    "call": {
      "id": 5003,
      "direction": "outbound",
      "status": "ringing",
      "to_number": "7700000XXXX",
      "from_number": "7727000XXXX",
      "asterisk_channel_id": "1720787200.4540",
      "timeline_message_id": 88230
    }
  }
}

Звонок без channel_id, запрещённый маршрутом (422)

Запрос

bash
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
  }'

Ответ

json
{
  "data": {
    "message": "This call is not allowed by your outbound route.",
    "block_reason": "international_not_allowed",
    "eligibility": null
  }
}

WhatsApp-звонок без разрешения контакта (422)

Запрос

bash
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
  }'

Ответ

json
{
  "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
      }
    }
  }
}

Завершение активного звонка

Запрос

bash
curl -X POST https://api.aisar.app/v1/calls/5001/hangup \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "message": "Hangup requested."
}

Телефония недоступна для аккаунта (403)

Запрос

bash
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
  }'

Ответ

json
{
  "data": {
    "message": "Telephony is not available for your account."
  }
}

Журнал звонков

Запрос

bash
curl "https://api.aisar.app/v1/calls?filter=missed&perPage=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "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
    }
  }
}

Архив записей: фильтр + сводка + без предподписи

Запрос

bash
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"

Ответ

json
{
  "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
    }
  }
}

Счётчики звонков

Запрос

bash
curl https://api.aisar.app/v1/calls/counters \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "data": {
    "all": 128,
    "missed": 6
  }
}

Расшифровка звонка (ИИ перевёл на оператора)

Запрос

bash
curl https://api.aisar.app/v1/calls/5010/transcript \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "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
  }
}

У звонка нет голосовой сессии (404)

Запрос

bash
curl https://api.aisar.app/v1/calls/5002/transcript \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "data": {
    "message": "No voice session for this call."
  }
}

Ссылка на запись звонка

Запрос

bash
curl "https://api.aisar.app/v1/calls/5010/recording-url?via=download" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "data": {
    "recording_url": "https://api.aisar.app/v1/messages/88250/attachments/4021/media?company=3&disposition=attachment&expires=1755651600&signature=8f2a1c9d4e6b7f0a3c5d8e1f2a4b6c9d0e3f5a7b8c1d2e4f6a9b0c3d5e7f8a1"
  }
}

Для звонка нет записи (404)

Запрос

bash
curl https://api.aisar.app/v1/calls/5002/recording-url \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Ответ

json
{
  "data": {
    "message": "No recording for this call."
  }
}

Очередь перезвона

Запрос

bash
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"

Ответ

json
{
  "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"
  }
}

Снять задачу из очереди перезвона

Запрос

bash
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" }'

Ответ

json
{
  "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
    }
  }
}

Задача уже закрыта (409)

Запрос

bash
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" }'

Ответ

json
{
  "data": {
    "message": "Callback already resolved."
  }
}

Статистика звонков за 7 дней

Запрос

bash
curl -G "https://api.aisar.app/v1/telephony/stats" \
  --data-urlencode "period=7d" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json"

Ответ

json
{
  "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 }
    ]
  }
}

Поиск слова в расшифровках звонков

Запрос

bash
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"

Ответ

json
{
  "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
    }
  }
}

Поиск по расшифровкам без запроса (422)

Запрос

bash
curl -G "https://api.aisar.app/v1/calls" \
  --data-urlencode "q_in=transcript" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json"

Ответ

json
{
  "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."
      ]
    }
  }
}

Список исходящих схем

Запрос

bash
curl -G "https://api.aisar.app/v1/telephony/schemes" \
  --data-urlencode "direction=outbound" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json"

Ответ

json
{
  "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"
    }
  ]
}

Превью линии дозвона

Запрос

bash
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"

Ответ

json
{
  "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]
  }
}

Превью линии из embed-виджета (урезанный ответ)

Запрос

bash
# 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"

Ответ

json
{
  "data": {
    "blocked": false,
    "block_reason": null,
    "default_channel_id": 41,
    "dial_mode": "dial_direct",
    "allowed_channel_ids": null
  }
}

Назначить операторов и команду на исходящую схему

Запрос

bash
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] }'

Ответ

json
{
  "data": {
    "assignments": {
      "operators": [
        { "id": 14, "name": "Данияр О." },
        { "id": 27, "name": "Асем Б." }
      ],
      "teams": [
        { "id": 5, "name": "Продажи" }
      ],
      "conflicts": []
    },
    "moved": {
      "operators": [],
      "teams": []
    }
  }
}

Политика исходящих компании: прочитать и изменить

Запрос

bash
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 }'

Ответ

json
{
  "data": {
    "default_channel_id": 412,
    "default_channel": { "id": 412, "name": "Городской номер", "number": "+77010000000" },
    "default_channel_available": true,
    "manual_pick_enabled": false
  }
}

Звонок с номером вне основной схемы при выключенном выборе (422)

Запрос

bash
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 }'

Ответ

json
{
  "data": {
    "message": "Choosing the number before a call is turned off for this company.",
    "error_code": "manual_pick_disabled"
  }
}

Попытка назначить операторов входящей схеме (422)

Запрос

bash
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": [] }'

Ответ

json
{
  "data": {
    "message": "The given data was invalid.",
    "errors": {
      "scheme": [
        "Only outbound schemes have operator assignments."
      ]
    }
  }
}

Настройки записи разговоров компании

Запрос

bash
curl "https://api.aisar.app/v1/companies/current/telephony/recording-settings" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json"

Ответ

json
{
  "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"
    }
  }
}

Включить удаление записей старше 6 месяцев

Запрос

bash
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
  }'

Ответ

json
{
  "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"
    }
  }
}

Тот же запрос без подтверждения (422, плоское тело)

Запрос

bash
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 }'

Ответ

json
{
  "message": "Shortening the retention term deletes recordings irreversibly and requires an explicit confirmation.",
  "error_code": "retention_shortening_requires_confirmation"
}

Что уничтожит срок в 6 месяцев (превью)

Запрос

bash
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"

Ответ

json
{
  "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"
  }
}

Создать входящую схему

Запрос

bash
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" }'

Ответ

json
{
  "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": []
    }
  }
}

Загрузить аудио-приглашение

Запрос

bash
curl -X POST "https://api.aisar.app/v1/telephony/audio-prompts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -F "name=Приветствие" \
  -F "file=@greeting.mp3"

Ответ

json
{
  "data": {
    "item": {
      "id": 12,
      "name": "Приветствие",
      "status": "ready",
      "original_filename": "greeting.mp3",
      "created_at": "2026-10-16T08:30:00+00:00"
    }
  }
}

Граф входящей схемы: приветствие, расписание, IVR 1/2, две группы, голосовая почта

Запрос

bash
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" }
  ]
}'

Ответ

json
{
  "data": {
    "scheme": {
      "id": "0199a1e2-7c3a-7f4e-9c2b-6a1d8e7b1c40",
      "direction": "inbound",
      "status": "draft",
      "nodes": ["... 7 узлов с новыми uuid / 7 nodes with fresh uuids ..."],
      "edges": ["... 9 рёбер / 9 edges ..."]
    }
  }
}

Привязать канал и включить схему

Запрос

bash
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" }'

Ответ

json
{
  "data": {
    "message": "Scheme channels updated."
  }
}