C cenaly.ru
Начать бесплатно

Вебхуки событий звонков

События звонка на ваш сервер в реальном времени: создание вебхука, тела пяти событий, проверка подписи на Node.js, Python, PHP и Go, повторы и дубли, лента событий без своего сервера, отладка

9 мин чтения Демо админ-панели
На этой странице 19

Вебхуки событий звонков

Для интегратора 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 и расчёт метрик сложнее простого подсчёта событий.

Отладка#

  1. Поднимите локальный HTTP-приёмник, например на порту 8080, и дайте ему публичный HTTPS-туннель: ngrok http 8080. В вебхуке укажите выданный HTTPS URL с путём приёмника. localhost, частные адреса, HTTP и редиректы для доставки не подходят.
  2. Сохраните секрет; нажмите «Проверить». В журнале проверьте HTTP-код и ошибку. Убедитесь, что маршрут принимает POST без браузерного входа и CSRF-проверки формы.
  3. При несовпадении подписи сравните исходные байты, секрет, точку-разделитель и часы. Не логируйте секрет или полное тело звонка в общий журнал.
  4. Проверьте, что повтор одного id не создаёт вторую запись; затем пришлите события тестового сценария в обратном порядке.
  5. После ping сделайте разрешённый тестовый звонок: ping не проверяет фильтр событий, точки и включение событий на АТС.
  6. Проверьте доступность публичного DNS/TLS и ответ быстрее 10 секунд. При смене адреса туннеля обновите подписку.

События обработки аудио и готовности расшифровки относятся к источнику «Вебхук» раздела «Звонки». В этих пяти типах событий АТС уведомления «расшифровка готова» нет.

Смотрите также#

Была ли статья полезной?