Перейти к основному содержимому

Вебхуки

Вебхук — запрос, который телефон отправляет на выбранный вами адрес каждый раз, когда что-то происходит со звонком. Так CRM может открыть карточку клиента до второго гудка, записать звонок в журнал, когда он закончится, или зажечь лампу на табло. Вебхуку не нужны правила для входящих соединений в межсетевом экране: телефон сам обращается к вам. Поскольку запросы отправляются с рабочего места, адрес должен быть доступен только с этого компьютера — внутренний http://crm.local/calls подходит так же, как публичный адрес HTTPS.

После установки вебхуки выключены, пока вы их не включите. Они работают вместе с локальным REST API: событие сообщает, что что-то изменилось, а API даёт текущие подробности.

Включение​

Откройте Настройки → Интеграция. Вебхуки — первый раздел вкладки.

  1. Отметьте Сообщать о звонках другой системе. На каждое отмеченное ниже событие отправляется запрос.
  2. Введите Адрес, который должен получать события, например https://crm.local/calls.
  3. Выберите Метод: POST (по умолчанию) или GET.
  4. В Событиях отметьте, что отправлять: Новый звонок, Завершение звонка, Изменение состояния звонка.
  5. При желании в Авторизации задайте заголовок, который может проверять ваш получатель: Имя заголовка (предлагается Authorization) и Значение заголовка. Значение хранится в хранилище ключей компьютера, а не в файле настроек; после сохранения поле показывает Сохранено — введите, чтобы заменить.
  6. Нажмите Отправить тестовое событие, чтобы убедиться, что оно доходит. Отправляется одно событие о звонке, которого не было, с теми же заголовками, что и настоящее. Запишите сырой запрос и стройте получателя по тому, что на самом деле отправляет ваша версия.

Часть программы, которая отправляет запросы, — модуль Интеграция; его можно отключить в Модулях.

События​

Отмечается какСобытиеКогда отправляется
Новый звонок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-startedringing-indialing
call-state-changedactiveringing-out, затем active
call-endedendedended

Поля​

Каждое значение — строка, включая числа и метки времени: "duration_s": "42". Неизвестный момент — пустая строка. Имена следуют одному правилу: _id — идентификатор, _ts — время Unix в миллисекундах (UTC), _s — длительность в секундах; так же, как в REST API, где значения — числа JSON.

ПолеЗначение
eventcall-started, call-state-changed или call-ended.
idЗвонок: тот же UUID, что в GET /calls и /calls/{id}/…, одинаковый во всех событиях звонка.
seance_idРазговор, к которому относится звонок; см. ниже.
directionin или out.
stateТе же значения, что в GET /calls: dialing, ringing-out, ringing-in, active, hold (удержан этим телефоном), onhold (удержан другой стороной), conference или ended.
numberНомер собеседника. Сопоставляйте записи CRM по этому полю.
nameИмя собеседника из Контактов; может быть пустым и заполниться позже по ходу звонка, как в исходящем звонке выше.
uriSIP-адрес собеседника.
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_byno, если ответил человек; иначе — что ответило на звонок.

Один разговор через переводы​

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 одним.