Вебхуки событий звонков
Для интегратора CRM и владельца бизнеса: вебхук сообщает вашей системе о звонке, пока он идёт. Можно открыть карточку клиента у сотрудника, создать задачу после пропущенного или собрать свою статистику. Нужен публичный HTTPS-приёмник; без него используйте опрос ленты событий. Ключи и права — в обзоре API и MCP своей АТС.
В статье: создание, события, подпись, доставка, опрос, отладка.
Создать вебхук#
Откройте Телефония → API и MCP → Вебхуки, добавьте название и публичный HTTPS-адрес, выберите точки и события. Сохраните секрет whsec_…: он показывается один раз. Кнопка «Проверить» отправит webhook.ping. До 10 вебхуков на аккаунт; название до 60 символов, URL до 500.
Для API нужен ключ с отдельно выданным webhooks:write. Пустые events означают все пять событий звонков; пустые locations — все точки. Ключ, ограниченный точками, должен явно задать разрешённые locations и не может управлять вебхуком на весь аккаунт.
curl -sS 'https://api.cenaly.ru/pbx-api/v1/webhooks' \
-H 'Authorization: Bearer cpx_…' -H 'Content-Type: application/json' \
-d '{"name":"CRM","url":"https://crm.example.com/cenaly/events","events":["call.started","call.ended"],"locations":["loc1"],"enabled":true}'
Пример ответа (время, id и секрет вымышлены):
{
"ok": true,
"created": true,
"webhook": {
"id": "wh_0123456789ab",
"name": "CRM",
"url": "https://crm.example.com/cenaly/events",
"events": [
"call.started",
"call.ended"
],
"locations": [
"loc1"
],
"enabled": true,
"createdAt": "2026-10-24T08:00:00Z",
"secretHint": "whsec_exampl"
},
"secret": "whsec_…"
}
PATCH /v1/webhooks/{id} изменяет переданные настройки. POST …/{id}/rotate-secret выдаёт новый секрет и сразу отменяет старый; DELETE …/{id} удаляет подписку. При ротации согласуйте смену секрета с приёмником.
События отправляет облако Cenaly. Адрес отправителя может меняться — в том числе когда доставку начнёт выполнять сам сервер АТС, — поэтому подлинность проверяйте по подписи тела, а не по IP.
Типы и тела событий#
| Тип | Когда возникает | Особые поля data |
|---|---|---|
call.started |
Начало звонка | направление, стороны и линия, если известна |
call.ringing |
У телефона впервые зазвонило | party: у кого; может быть несколько событий на один звонок |
call.answered |
Первый человек ответил | party: кто ответил |
call.transferred |
Перевод разговора | party: кому, by: кто перевёл |
call.ended |
Звонок завершён | status, длительности, answeredBy, hangupBy при наличии |
webhook.ping |
Нажали «Проверить» | служебное сообщение, не звонок |
Обёртка всегда содержит id, type, createdAt, locationId, webhookId, attempt, data. В звонковых событиях data — исходное событие с id, type, at, callId, seq. createdAt берётся из времени события; это не время повторной доставки. Необязательные поля могут отсутствовать, нулевые длительности обычно не передаются.
Полные примеры отдельных тел ниже — вымышленный звонок. Поля линии, транка и сторон зависят от маршрута, поэтому нельзя требовать их присутствия всегда.
call.started#
{
"id": "1792836000.1:1",
"type": "call.started",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:1",
"type": "call.started",
"at": "2026-10-24T10:00:00Z",
"callId": "1792836000.1",
"seq": 1,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
}
}
}
call.ringing#
{
"id": "1792836000.1:2",
"type": "call.ringing",
"createdAt": "2026-10-24T10:00:01Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:2",
"type": "call.ringing",
"at": "2026-10-24T10:00:01Z",
"callId": "1792836000.1",
"seq": 2,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.answered#
{
"id": "1792836000.1:3",
"type": "call.answered",
"createdAt": "2026-10-24T10:00:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:3",
"type": "call.answered",
"at": "2026-10-24T10:00:05Z",
"callId": "1792836000.1",
"seq": 3,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.transferred#
{
"id": "1792836000.1:4",
"type": "call.transferred",
"createdAt": "2026-10-24T10:00:30Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:4",
"type": "call.transferred",
"at": "2026-10-24T10:00:30Z",
"callId": "1792836000.1",
"seq": 4,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "102",
"name": "Иван"
},
"by": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.ended#
{
"id": "1792836000.1:5",
"type": "call.ended",
"createdAt": "2026-10-24T10:01:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:5",
"type": "call.ended",
"at": "2026-10-24T10:01:05Z",
"callId": "1792836000.1",
"seq": 5,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"status": "answered",
"answeredBy": {
"kind": "operator",
"extension": "101",
"name": "Анна"
},
"durationSec": 65,
"talkSec": 60,
"ringSec": 5
}
}
Дополнительно могут приходить lineId, lineLabel, line (id, number, caption, resolution, source), trunk (id, name), redirectedFrom, transferOf, hangupBy. У сторон могут быть operatorId и connectionId. Сохраняйте неизвестные поля или игнорируйте их, не отвергайте событие при расширении схемы.
webhook.ping#
{
"id": "ping:example",
"type": "webhook.ping",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"message": "Cenaly: проверка вебхука / webhook test"
}
}
locationId теста может быть пустым. Проверьте подпись, ответьте 2xx, не создавайте звонок или задачу в CRM.
Заголовки#
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
X-Cenaly-Signature |
t=<unix>,v1=<hex HMAC-SHA256> |
X-Cenaly-Event |
Тип события |
X-Cenaly-Delivery |
Id события, тот же при повторе |
X-Cenaly-Webhook |
Id подписки |
User-Agent |
Cenaly-Webhooks/1.0 (+https://cenaly.com/docs/pbx-api) |
Проверка подписи#
Подписаны исходные байты тела: HMAC-SHA256(secret, t + "." + rawBody). Секрет используется целиком, включая whsec_, без base64-декодирования. Не разбирайте и не сериализуйте JSON заново перед проверкой. Примеры отвергают timestamp дальше 300 секунд от часов приёмника в любую сторону и сравнивают подпись за постоянное время. Синхронизируйте часы.
Node.js#
Функция получает Buffer до JSON middleware, например собранный из потока HTTP-запроса. Секрет берите из защищённых настроек, не из заголовка запроса.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(raw, header, secret, now = Date.now() / 1000) {
const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m || Math.abs(now - Number(m[1])) > 300) return false;
const expected = createHmac('sha256', secret)
.update(m[1] + '.').update(raw).digest();
return timingSafeEqual(expected, Buffer.from(m[2], 'hex'));
}
Python#
raw — bytes, например request.get_data() до разбора JSON.
import hashlib, hmac, re, time
def verify(raw, header, secret, now=None):
m = re.fullmatch(r"t=(\d{1,12}),v1=([0-9a-f]{64})", header or "")
if not m or abs((time.time() if now is None else now) - int(m[1])) > 300:
return False
expected = hmac.new(secret.encode(), m[1].encode() + b"." + raw,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m[2])
PHP#
<?php
function verifySignature(string $raw, string $header, string $secret): bool {
if (!preg_match('/\At=(\d{1,12}),v1=([0-9a-f]{64})\z/', $header, $m)) return false;
if (abs(time() - (int)$m[1]) > 300) return false;
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($expected, $m[2]);
}
$raw = file_get_contents('php://input');
if (!verifySignature($raw, $_SERVER['HTTP_X_CENALY_SIGNATURE'] ?? '', getenv('PBX_WEBHOOK_SECRET') ?: '')) {
http_response_code(401);
exit;
}
// Перед ответом надёжно сохраните событие в очередь; при сбое сохранения верните 503.
Go#
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var signatureRE = regexp.MustCompile(`^t=([0-9]{1,12}),v1=([0-9a-f]{64})$`)
func Verify(raw []byte, header, secret string, now time.Time) bool {
m := signatureRE.FindStringSubmatch(header)
if m == nil { return false }
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || ts < now.Unix()-300 || ts > now.Unix()+300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(raw)
got, err := hex.DecodeString(m[2])
return err == nil && hmac.Equal(mac.Sum(nil), got)
}
После успешной проверки разбирайте JSON, проверяйте нужные поля и сохраняйте событие. Ограничьте размер входящего HTTP-тела на своём сервере. При неверной подписи — 401, при невозможности сохранить — 5xx, после надёжного приёма — 200 или 204.
Доставка, повторы и порядок#
Ответьте 2xx за 10 секунд. Тяжёлую работу выполняйте из своей очереди после ответа, но сохраните событие до подтверждения. 3xx не перенаправляется; 4xx, 5xx, таймаут и сетевая ошибка считаются неудачей.
| Следующая попытка | Пауза после предыдущей |
|---|---|
| 2 | 10 секунд |
| 3 | 30 секунд |
| 4 | 2 минуты |
| 5 | 5 минут |
| 6 | 10 минут |
| 7 | 15 минут |
| 8 | 15 минут |
Всего 8 попыток, сумма пауз 47 минут 40 секунд плюс время запросов и очереди. Это не точное расписание по часам. webhook.ping отправляется один раз, без повторов.
id звонкового события — <callId>:<seq>. При повторе id прежний, attempt увеличивается, подпись и её timestamp вычисляются заново. В хранилище приёмника сделайте уникальность (webhookId, id), если принимаете несколько подписок, или id в рамках одной подписки. Повтор уже сохранённого события тоже подтверждайте 2xx. Постановку задания и запись id делайте одной транзакцией: иначе возможны потери или двойные задачи.
Порядок HTTP-доставок не гарантирован: повтор call.started может прийти после call.ended. Собирайте состояние по locationId, data.callId и data.seq, не откатывайте завершённый звонок в «звонит». Пропуск seq не обязательно означает потерю: подписка могла исключить промежуточные типы. Лента событий и журнал звонков помогают сверить итог; вебхуки не заменяют бессрочный архив.
После серии неудачных реальных доставок длительностью не менее 72 часов вебхук выключается автоматически, владелец получает уведомление. Успешная реальная доставка сбрасывает серию; тестовый ping не влияет на неё. Отсутствие звонков само по себе не выключает вебхук.
В окне появятся «Выключен автоматически» и «Включить». После исправления приёмника включите там или отправьте PATCH /v1/webhooks/{id} с {"enabled":true}. Исторические пропуски сверяйте отдельно.
Журнал — в окне подписки и GET /v1/webhooks/{id}/deliveries: до 100 последних попыток, время, событие, номер попытки, HTTP-код, ошибка и результат. Код 0 означает, что HTTP-ответа не получили. POST /v1/webhooks/{id}/test ставит ping в очередь; принятие команды не означает успешную доставку — прочитайте журнал.
Без своего сервера#
Право calls:read даёт GET /v1/events и MCP get_call_events. Это исходные события, без webhook-обёртки, attempt и подписи. Лента хранит до 2000 событий точки за 7 дней. Параметры: since — id или ISO-время, types — типы через запятую, limit — до 500.
curl -sS -G 'https://api.cenaly.ru/pbx-api/v1/events' \
-H 'Authorization: Bearer cpx_…' \
--data-urlencode 'location=loc1' --data-urlencode 'since=1792836000.1:5' \
--data-urlencode 'limit=100'
{
"location": "loc1",
"events": [],
"more": false
}
Если события есть, ответ содержит next — id последнего выданного. Сохраните его после обработки и передайте как since следующего запроса. При more:true продолжайте немедленно, при false выдержите паузу в рамках лимита API. Не повторяйте прежний since для следующей страницы.
Лента ограничена окном хранения: если приёмник молчал дольше 7 дней или пропустил больше 2000 событий, старые уже не вернуть — сверяйте итог с журналом звонков.
MCP-вызов: get_call_events с {"location":"loc1","since":"1792836000.1:5","types":["call.ended"],"limit":100}.
Рецепты#
Карточка клиента в CRM. На call.started ищите контакт по нормализованному data.from.number. На call.ringing показывайте карточку сотруднику из data.party.extension; на call.answered отмечайте принявшего, на переводе меняйте адресата. На завершении закрывайте индикатор звонка. Сопоставление добавочного и сотрудника CRM храните у себя.
Пропущенный → задача. На call.ended с direction:"inbound" и status из missed, busy, cancelled, abandoned создайте одну задачу по callId. Отфильтруйте нежелательные короткие вызовы своей политикой. Не делайте задачу по одному call.ringing: ответить мог другой телефон.
Статистика. Считайте завершённые звонки один раз, отдельно направления и исходы; длительности берите из durationSec, talkSec, ringSec, учитывая отсутствие нулевых значений. Для сравнения с аналитикой Cenaly используйте /v1/metrics (REST API): правила SLA и расчёт метрик сложнее простого подсчёта событий.
Отладка#
- Поднимите локальный HTTP-приёмник, например на порту 8080, и дайте ему публичный HTTPS-туннель:
ngrok http 8080. В вебхуке укажите выданный HTTPS URL с путём приёмника.localhost, частные адреса, HTTP и редиректы для доставки не подходят. - Сохраните секрет; нажмите «Проверить». В журнале проверьте HTTP-код и ошибку. Убедитесь, что маршрут принимает POST без браузерного входа и CSRF-проверки формы.
- При несовпадении подписи сравните исходные байты, секрет, точку-разделитель и часы. Не логируйте секрет или полное тело звонка в общий журнал.
- Проверьте, что повтор одного id не создаёт вторую запись; затем пришлите события тестового сценария в обратном порядке.
- После ping сделайте разрешённый тестовый звонок: ping не проверяет фильтр событий, точки и включение событий на АТС.
- Проверьте доступность публичного DNS/TLS и ответ быстрее 10 секунд. При смене адреса туннеля обновите подписку.
События обработки аудио и готовности расшифровки относятся к источнику «Вебхук» раздела «Звонки». В этих пяти типах событий АТС уведомления «расшифровка готова» нет.