Вебхуки
Вебхук — запрос, который телефон отправляет на выбранный вами адрес каждый раз, когда что-то происходит со звонком. Так CRM может открыть карточку клиента до второго гудка, записать звонок в журнал, когда он закончится, или зажечь лампу на табло. Вебхуку не нужны правила для входящих соединений в межсетевом экране: телефон сам обращается к вам. Поскольку запросы отправляются с рабочего места, адрес должен быть доступен только с этого компьютера — внутренний http://crm.local/calls подходит так же, как публичный адрес HTTPS.
После установки вебхуки выключены, пока вы их не включите. Они работают вместе с локальным REST API: событие сообщает, что что-то изменилось, а API даёт текущие подробности.
Включение
Откройте Настройки → Интеграция. Вебхуки — первый раздел вкладки.
- Отметьте Сообщать о звонках другой системе. На каждое отмеченное ниже событие отправляется запрос.
- Введите Адрес, который должен получать события, например
https://crm.local/calls. - Выберите Метод: POST (по умолчанию) или GET.
- В Событиях отметьте, что отправлять: Новый звонок, Завершение звонка, Изменение состояния звонка.
- При желании в Авторизации задайте заголовок, который может проверять ваш получатель: Имя заголовка (предлагается
Authorization) и Значение заголовка. Значение хранится в хранилище ключей компьютера, а не в файле настроек; после сохранения поле показывает Сохранено — введите, чтобы заменить. - Нажмите Отправить тестовое событие, чтобы убедиться, что оно доходит. Отправляется одно событие о звонке, которого не было, с теми же заголовками, что и настоящее. Запишите сырой запрос и стройте получателя по тому, что на самом деле отправляет ваша версия.
Часть программы, которая отправляет запросы, — модуль Интеграция; его можно отключить в Модулях.
События
| Отмечается как | Событие | Когда отправляется |
|---|---|---|
| Новый звонок | call-started | Входящий звонок начинает звонить или совершается исходящий. |
| Изменение состояния звонка | call-state-changed | Меняется state звонка: на него ответили, его удержала или сняла с удержания любая сторона, он вошёл в конференцию или вышел из неё. Выключение микрофона это событие не отправляет. |
| Завершение звонка | call-ended | Звонок закончился. |
Каждое событие можно отметить отдельно. Всплывающей карточке нужно только первое, журналу звонков — только последнее. call-started отправляется первым, и обрабатывать его стоит быстро.
Как выглядит запрос
С адресом https://crm.local/calls и методом POST телефон отправляет вот это. Тело — JSON, а заголовок — тот, что вы задали в Авторизации:
POST /calls HTTP/1.1
Host: crm.local
Authorization: Bearer my-secret-token
Content-Type: application/json
User-Agent: ai-softphone/1.0.1-macos-dmg
User-Agent несёт версию программы и способ её установки.
Входящий звонок, событие за событием
Звонок с внутреннего номера 1020 на учётную запись 1002 звонит, на него отвечают, и через четыре секунды ответивший кладёт трубку. Если отмечены все три события, получатель получает три запроса, один за другим. У всех один и тот же id и seance_id.
1. Звонит: call-started
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791021263333",
"callstate_ts": "1791021263333",
"dialed": "",
"direction": "in",
"duration_s": "0",
"event": "call-started",
"event_ts": "1791021263333",
"id": "5b1c0a6e-0c7e-4c53-9f57-2c4f1f0d6a11",
"name": "Иван Петров",
"number": "1020",
"reason": "none",
"seance_id": "e3f0b9f4-1a2c-4d8b-9c35-6a7b8c9d0e1f",
"state": "ringing-in",
"uri": "sip:1020@pbx.example.com"
}
Это момент найти звонящего по number и показать карточку клиента. state — ringing-in, duration_s — 0.
2. Ответили: call-state-changed
Примерно через три секунды:
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791021263333",
"callstate_ts": "1791021266126",
"dialed": "",
"direction": "in",
"duration_s": "0",
"event": "call-state-changed",
"event_ts": "1791021266131",
"id": "5b1c0a6e-0c7e-4c53-9f57-2c4f1f0d6a11",
"name": "Иван Петров",
"number": "1020",
"reason": "none",
"seance_id": "e3f0b9f4-1a2c-4d8b-9c35-6a7b8c9d0e1f",
"state": "active",
"uri": "sip:1020@pbx.example.com"
}
Теперь state — active, а callstate_ts сдвинулся на момент изменения, тогда как callstart_ts остался прежним.
3. Закончился: call-ended
Через четыре секунды разговора:
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791021263333",
"callstate_ts": "1791021270400",
"dialed": "",
"direction": "in",
"duration_s": "4",
"event": "call-ended",
"event_ts": "1791021270406",
"id": "5b1c0a6e-0c7e-4c53-9f57-2c4f1f0d6a11",
"name": "Иван Петров",
"number": "1020",
"reason": "local-hangup",
"seance_id": "e3f0b9f4-1a2c-4d8b-9c35-6a7b8c9d0e1f",
"state": "ended",
"uri": "sip:1020@pbx.example.com"
}
state — ended, duration_s — длительность разговора, а reason говорит, кто его закончил: здесь local-hangup, потому что трубку положил человек у этого телефона.
Исходящий звонок, событие за событием
На тот же номер звонят с учётной записи 1002: человек набирает 1020, у собеседника звонит, тот отвечает, говорит семь секунд и кладёт трубку. Получатель получает четыре запроса — на один больше, чем для входящего, потому что у исходящего звонка есть своё состояние, пока у собеседника звонит.
1. Набран: call-started
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791023883072",
"callstate_ts": "1791023883072",
"dialed": "1020",
"direction": "out",
"duration_s": "0",
"event": "call-started",
"event_ts": "1791023883072",
"id": "9a3e5c71-2d48-4b6f-8e10-3c5f7a1b9d24",
"name": "",
"number": "1020",
"reason": "none",
"seance_id": "c47d2e90-6b13-4f85-a2d7-18e9b0f35a6c",
"state": "dialing",
"uri": "sip:1020@pbx.example.com:5060"
}
direction — out, state — dialing, а dialed содержит номер так, как его набрали. Имени собеседника телефон ещё не знает, поэтому name пуст.
2. У собеседника звонит: call-state-changed
Через полсекунды:
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791023883072",
"callstate_ts": "1791023883591",
"dialed": "1020",
"direction": "out",
"duration_s": "0",
"event": "call-state-changed",
"event_ts": "1791023883591",
"id": "9a3e5c71-2d48-4b6f-8e10-3c5f7a1b9d24",
"name": "",
"number": "1020",
"reason": "none",
"seance_id": "c47d2e90-6b13-4f85-a2d7-18e9b0f35a6c",
"state": "ringing-out",
"uri": "sip:1020@pbx.example.com:5060"
}
state — ringing-out.
3. Собеседник ответил: call-state-changed
Ещё через четыре секунды:
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791023883072",
"callstate_ts": "1791023887072",
"dialed": "1020",
"direction": "out",
"duration_s": "0",
"event": "call-state-changed",
"event_ts": "1791023887076",
"id": "9a3e5c71-2d48-4b6f-8e10-3c5f7a1b9d24",
"name": "Иван Петров",
"number": "1020",
"reason": "none",
"seance_id": "c47d2e90-6b13-4f85-a2d7-18e9b0f35a6c",
"state": "active",
"uri": "sip:1020@pbx.example.com"
}
state — active. Теперь name заполнено, а uri — адрес собеседника так, как его сообщил ответ. duration_s всё ещё 0: отсчёт идёт с этого момента.
4. Закончился: call-ended
Через семь секунд собеседник кладёт трубку:
{
"account": "1002@pbx.example.com",
"account_id": "0d7a4c52-6f2e-4a51-8f46-7d9a3e1b2c90",
"answered_by": "no",
"callstart_ts": "1791023883072",
"callstate_ts": "1791023894781",
"dialed": "1020",
"direction": "out",
"duration_s": "7",
"event": "call-ended",
"event_ts": "1791023894789",
"id": "9a3e5c71-2d48-4b6f-8e10-3c5f7a1b9d24",
"name": "Иван Петров",
"number": "1020",
"reason": "remote-hangup",
"seance_id": "c47d2e90-6b13-4f85-a2d7-18e9b0f35a6c",
"state": "ended",
"uri": "sip:1020@pbx.example.com"
}
duration_s — 7, а reason — remote-hangup, потому что звонок закончила другая сторона. Когда трубку кладёте вы, это local-hangup, как во входящем звонке выше.
Состояния рядом
| Входящий звонок | Исходящий звонок | |
|---|---|---|
call-started | ringing-in | dialing |
call-state-changed | active | ringing-out, затем active |
call-ended | ended | ended |
Поля
Каждое значение — строка, включая числа и метки времени: "duration_s": "42". Неизвестный момент — пустая строка. Имена следуют одному правилу: _id — идентификатор, _ts — время Unix в миллисекундах (UTC), _s — длительность в секундах; так же, как в REST API, где значения — числа JSON.
| Поле | Значение |
|---|---|
event | call-started, call-state-changed или call-ended. |
id | Звонок: тот же UUID, что в GET /calls и /calls/{id}/…, одинаковый во всех событиях звонка. |
seance_id | Разговор, к которому относится звонок; см. ниже. |
direction | in или out. |
state | Те же значения, что в GET /calls: dialing, ringing-out, ringing-in, active, hold (удержан этим телефоном), onhold (удержан другой стороной), conference или ended. |
number | Номер собеседника. Сопоставляйте записи CRM по этому полю. |
name | Имя собеседника из Контактов; может быть пустым и заполниться позже по ходу звонка, как в исходящем звонке выше. |
uri | SIP-адрес собеседника. |
dialed | Набранные цифры — для исходящего звонка; для входящего пусто. |
account, account_id | Линия звонка: логин@сервер и идентификатор из GET /accounts. |
event_ts | Когда произошло событие. |
callstart_ts | Когда телефон впервые узнал о звонке. |
callstate_ts | Когда звонок вошёл в текущий state. |
duration_s | Время разговора в секундах, от ответа до отбоя. Заполняется в call-ended для отвеченного звонка; иначе 0. |
reason | Чем закончился звонок: local-hangup, remote-hangup, busy, no-answer, cancelled…; до этого — none. |
answered_by | no, если ответил человек; иначе — что ответило на звонок. |
Один разговор через переводы
seance_id объединяет звонки, из которых состоит один разговор. Звонок, сделанный или принятый с нуля, начинает новый. Звонок, созданный переводом, звонок, заменивший другой, консультация по звонку и каждый звонок, вошедший в конференцию, сохраняют seance_id звонка, из которого они вышли.
Между телефонами он передаётся в SIP-заголовке X-Seance-Id: когда звонок переводят коллеге, у которого тоже AI Softphone, а АТС передаёт заголовок дальше, оба рабочих места сообщают один и тот же seance_id.
GET вместо POST
GET — для получателей, которые не принимают тело запроса, например старой CRM или скрипта-моста. Те же поля тогда передаются параметрами запроса.
С GET адрес может быть шаблоном: каждое [поле] заменяется значением этого поля в процентной кодировке. Например:
https://crm.local/pop?phone=[number]&call=[id]
В подстановках используются имена полей выше. Шаблоны, сохранённые со старыми именами ([accountId], [at], [duration], [answeredBy], [seanceId]), продолжают работать.
Как доставляются события
| Поведение | Что это значит для вас |
|---|---|
| События ставятся в очередь, а не отправляются из самого звонка | Медленный получатель никогда не задерживает звонок, разговор или перевод. |
| При полной очереди события выбрасываются | Если получатель перестал отвечать, события теряются, но телефон продолжает работать. Следите за webhooks_dropped_total. |
| Отказы и недоставки считаются | Рост webhooks_failed_total при неподвижном webhooks_delivered_total указывает на получателя. |
| События приходят по порядку | Звонок начался, потом изменения состояния, потом звонок закончился. Чтобы упорядочить сохранённые события, используйте callstate_ts, а не время прихода. |
| Хотя бы один раз | Одно и то же событие может прийти дважды. id, event и callstate_ts вместе определяют событие: пусть обработчик пропускает уже виденное. |
Приём событий
Единственное правило для получателя: сразу отвечайте 200, а работу делайте потом. Медленный получатель не тормозит телефон, но заполняет очередь, а полная очередь выбрасывает события.
Например, на Node.js с Express:
const express = require("express");
const app = express();
app.use(express.json());
const SECRET = process.env.SOFTPHONE_SECRET; // the Header value from Settings
app.all("/calls", (req, res) => {
if (req.get("Authorization") !== SECRET) return res.sendStatus(401);
// POST sends a JSON body, GET sends query parameters
const call = Object.keys(req.body || {}).length ? req.body : req.query;
res.sendStatus(200); // answer first
setImmediate(() => { // then do the work
if (call.event === "call-started" && call.direction === "in") {
openCustomerCard(call.number, call.name); // your code
}
if (call.event === "call-ended") {
logCall(call.id, Number(call.duration_s), call.reason); // your code
}
});
});
app.listen(8080);
Чтобы записать итог звонка — отвечен, пропущен, отклонён, — возьмите запись с тем же seance_id и number из GET /history?limit=20 REST API. Когда ваша служба снова запускается после перерыва, прочитайте GET /history?limit=200 и сохраните пропущенное: вебхуки — для реального времени, история — чтобы заполнить пробелы.
Чтобы увидеть запросы до того, как CRM будет готова, направьте Адрес на онлайн-инспектор запросов и нажмите Отправить тестовое событие.
Если ничего не приходит
| Признак | Что проверить |
|---|---|
| Вебхуков нет совсем | Нажмите Отправить тестовое событие. Если оно приходит — нужные события не отмечены; если нет — адрес неверен или недоступен с рабочего места. |
webhooks_failed_total растёт | Получатель отклоняет запросы или недоступен. Проверьте его журнал и то, отвечает ли он на простой запрос с рабочего места. |
webhooks_dropped_total больше нуля | Получатель слишком долго был слишком медленным, и очередь переполнилась. Сначала отвечайте 200, потом обрабатывайте. |
| Одно и то же событие дважды | Ожидаемо при доставке «хотя бы один раз». Считайте события с одинаковыми id, event и callstate_ts одним. |