PearlPBX2

Інтеграція CRM-систем з PearlPBX2: технічний посібник

Версія: 2.7.2


Цей документ описує процедуру інтеграції зовнішньої CRM-системи з платформою PearlPBX2. Він призначений для розробників, які не мають попереднього досвіду роботи з телефонними системами, і містить повний обсяг інформації, необхідної для реалізації інтеграції: модель подій, формат переданих даних, повний опис REST API (включно з ініціалізацією вихідних дзвінків), механізм отримання аудіозаписів розмов та вимоги до перевірки автентичності запитів.

Для роботи з документом достатньо базових навичок реалізації HTTP-обробників (webhook-ендпоінтів) та виконання автентифікованих запитів до REST API. Додаткових знань предметної області телефонії не вимагається.

Документ є самодостатнім джерелом інформації: усі відомості, потрібні для реалізації інтеграції, викладені нижче, без необхідності звертатися до додаткових джерел.


Зміст

  1. Загальна схема взаємодії
  2. Термінологія
  3. Розподіл відповідальності сторін
  4. Життєвий цикл дзвінка
  5. Опис подій та їх полів
  6. Отримання запису розмови
  7. REST API PearlPBX2
  8. Dashboard API та WebSocket: канал реального часу (опційно)
  9. Перевірка автентичності запитів
  10. Налаштування власного формату JSON
  11. Поведінка системи при збоях доставки
  12. Приклад реалізації сервера-приймача
  13. Питання, що часто виникають
  14. Контрольний список інтеграції

1. Загальна схема взаємодії

PearlPBX2 — система управління телефонією підприємства: приймає вхідні дзвінки, розподіляє їх між операторами через черги обслуговування та здійснює запис розмов. Для цілей інтеграції CRM-системі потрібні три відомості: момент початку дзвінка, момент його завершення та розташування аудіозапису (якщо запис здійснювався).

Інтеграція реалізована на основі двох механізмів:

Розділи нижче описують кожен з цих механізмів детально.

2. Термінологія

3. Розподіл відповідальності сторін

Таким чином, взаємодія відбувається у двох напрямках: вхідному (веб-хуки, що надходять від АТС) та вихідному (запити CRM до API для отримання записів).

4. Життєвий цикл дзвінка

Розглянемо послідовність подій на прикладі одного дзвінка. Клієнт здійснює виклик до служби підтримки підприємства.

Крок 1. Дзвінок надходить до системи. За умови відповідності критеріям, визначеним адміністратором (певний вхідний напрямок або черга), на сервер CRM надсилається подія call.incoming. Повідомлення містить uniqueid, номер абонента та попередню оцінку (prediction) щодо здійснення запису (детально — у розділі 6).

На цьому етапі CRM-система, як правило, відображає картку поточного дзвінка — наприклад, спливаюче сповіщення оператору або чернетку запису в історії взаємодій з клієнтом.

Крок 2. Дзвінок надходить до черги обслуговування, абонент очікує з’єднання з оператором. Можливі два варіанти розвитку подій.

Варіант А: оператор приймає виклик. Надсилається подія call.answered, яка містить ім’я оператора, його внутрішній номер, тривалість дзвінка до відповіді та час очікування абонента в черзі. Ця подія призначена для відображення картки клієнта оператору в момент з’єднання.

Варіант Б: абонент завершує виклик, не дочекавшись відповіді. Надсилається подія call.missed (пропущений виклик). Ця подія надходить незалежно від того, чи підписана CRM-система на подію call.incoming — пропущений виклик розглядається як самостійно значуща подія.

Крок 3. Після завершення розмови (незалежно від того, відбулась вона одразу чи після кроку 2А) надсилається подія call.ended. Вона містить тривалість розмови, причину завершення, дані оператора, який прийняв виклик (за наявності події call.answered), а також посилання на аудіозапис, якщо розмова записувалась.

Ключове правило: подія call.ended надсилається виключно для дзвінків, про які раніше було повідомлено подією call.incoming. Система відстежує цей стан внутрішньо, тому отримання події завершення без попередньої події початку виключене, так само як і повторне надсилання події завершення для одного дзвінка. Події call.missed та call.answered, на відміну від цього, є самостійними та надходять незалежно від підписки на call.incoming.

Описаний вище ланцюг (call.incomingcall.answered/call.missedcall.ended) стосується дзвінків, що надходять до підприємства. Для дзвінків, які ініціює співробітник (наприклад, оператор передзвонює клієнту), існує окремий, повністю незалежний ланцюг подій.

Крок 1’ (вихідний дзвінок). Співробітник бере слухавку і набирає номер. На сервер CRM надсилається подія call.outgoing — аналог call.incoming, але для дзвінка, ініційованого зсередини АТС.

Крок 2’ (вихідний дзвінок). Якщо викликана сторона піднімає слухавку, надсилається подія call.outgoing_answered з часом відповіді. Якщо лінія зайнята, ніхто не відповідає, або виклик скасовано — ця подія не надсилається взагалі.

Крок 3’ (вихідний дзвінок). Після завершення дзвінка (незалежно від того, відповіли на нього чи ні) надсилається подія call.outgoing_ended. Вона містить поле answered, яке прямо каже, чи вдалось додзвонитися, тож CRM не потрібно вгадувати результат за кодом причини завершення.

Ці дві послідовності подій — вхідна та вихідна — завжди надходять окремо одна від одної, з різними назвами подій (call.ended проти call.outgoing_ended), навіть якщо в адмінці налаштовано лише один-єдиний веб-хук, підписаний одразу на обидва ланцюги. Ідентифікатор uniqueid дозволяє простежити всі події одного дзвінка незалежно від того, до якого ланцюга він належить.

Розділ 5 містить детальний опис кожної з семи подій.

5. Опис подій та їх полів

Усі запити мають тип POST з тілом у форматі application/json. Тип події визначається значенням поля event.

5.1. call.incoming — початок дзвінка

{
  "event": "call.incoming",
  "uniqueid": "1753000000.42",
  "linkedid": "1753000000.42",
  "channel": "PJSIP/trunk1-0000001a",
  "caller_id_num": "380501234567",
  "caller_id_name": "Іван Іванович",
  "exten": "s",
  "context": "incoming",
  "queue": null,
  "timestamp": "2026-07-21T18:58:51.811673",
  "recording_expected": null,
  "recording_url": "https://pbx.example.com/api/v1/recordings/1753000000.42/",
  "channel_vars": {}
}

Особливості полів:

5.2. call.answered — оператор прийняв виклик

{
  "event": "call.answered",
  "uniqueid": "1753000000.42",
  "linkedid": "1753000000.42",
  "channel": "PJSIP/trunk1-0000001a",
  "caller_id_num": "380501234567",
  "caller_id_name": "Іван Іванович",
  "queue": "support",
  "member_name": "Оператор Петренко",
  "member_interface": "PJSIP/101",
  "member_number": "101",
  "ringtime": "3500",
  "holdtime": "18",
  "timestamp": "2026-07-21T18:58:51.812900",
  "channel_vars": {"ULINE": "42"}
}

Опис полів:

Ця подія формується виключно для дзвінків, що пройшли через чергу обслуговування; пряме з’єднання поза чергою її не ініціює.

5.3. call.missed — пропущений виклик у черзі

{
  "event": "call.missed",
  "uniqueid": "1753000000.42",
  "linkedid": "1753000000.42",
  "channel": "PJSIP/trunk1-0000001a",
  "caller_id_num": "380501234567",
  "queue": "support",
  "wait_time": 21,
  "timestamp": "2026-07-21T18:58:51.813698",
  "channel_vars": {}
}

Клієнт очікував у черзі support протягом wait_time секунд (у прикладі — 21 секунду) і завершив виклик, не дочекавшись відповіді оператора. Ця подія призначена, зокрема, для автоматичного створення задачі зворотного дзвінка в CRM-системі.

5.4. call.ended — завершення дзвінка

{
  "event": "call.ended",
  "uniqueid": "1753000000.42",
  "linkedid": "1753000000.42",
  "channel": "PJSIP/trunk1-0000001a",
  "caller_id_num": "380501234567",
  "caller_id_name": "Іван Іванович",
  "exten": "s",
  "context": "incoming",
  "queue": null,
  "timestamp": "2026-07-21T18:58:51.814936",
  "duration": 42,
  "cause": "16",
  "cause_txt": "Normal Clearing",
  "answered_time": "38",
  "billsec": "38",
  "missed": false,
  "answered_by_member": "Оператор Петренко",
  "answered_by_interface": "PJSIP/101",
  "recorded": true,
  "recording_url": "https://pbx.example.com/api/v1/recordings/1753000000.42/",
  "recording_file": "/var/spool/asterisk/monitor/2026/07/21/x.wav",
  "channel_vars": {"ULINE": "42"}
}

Подія містить найбільший обсяг даних. Основні поля:

Примітка: не всі поля обов’язково заповнені для кожного дзвінка — наприклад, queue часто матиме значення null для прямих викликів. Рекомендується проєктувати обробку подій з урахуванням можливих null-значень.

5.5. call.outgoing — початок вихідного дзвінка

{
  "event": "call.outgoing",
  "uniqueid": "1753000000.55",
  "linkedid": "1753000000.55",
  "channel": "PJSIP/1001-0000002a",
  "caller_id_num": "1001",
  "caller_id_name": "Оператор Петренко",
  "exten": "380671112233",
  "context": "outbound-users",
  "direction": "outbound",
  "timestamp": "2026-08-11T18:58:51.811673",
  "channel_vars": {}
}

Особливості полів:

5.6. call.outgoing_answered — викликана сторона підняла слухавку

{
  "event": "call.outgoing_answered",
  "uniqueid": "1753000000.55",
  "linkedid": "1753000000.55",
  "channel": "PJSIP/1001-0000002a",
  "caller_id_num": "1001",
  "caller_id_name": "Оператор Петренко",
  "exten": "380671112233",
  "context": "outbound-users",
  "dest_channel": "PJSIP/trunk1-0000002a",
  "dial_status": "ANSWER",
  "direction": "outbound",
  "timestamp": "2026-08-11T18:58:56.203112",
  "channel_vars": {}
}

Опис полів:

Ця подія надходить, лише якщо виклик було прийнято. Якщо лінія зайнята, ніхто не відповів, або дзвінок скасовано до відповіді — ця подія не надсилається взагалі, і послідовність одразу переходить до call.outgoing_ended.

5.7. call.outgoing_ended — завершення вихідного дзвінка

{
  "event": "call.outgoing_ended",
  "uniqueid": "1753000000.55",
  "linkedid": "1753000000.55",
  "channel": "PJSIP/1001-0000002a",
  "caller_id_num": "1001",
  "caller_id_name": "Оператор Петренко",
  "exten": "380671112233",
  "context": "outbound-users",
  "queue": null,
  "direction": "outbound",
  "dial_status": "ANSWER",
  "answered": true,
  "timestamp": "2026-08-11T18:59:24.550012",
  "duration": 28,
  "cause": "16",
  "cause_txt": "Normal Clearing",
  "answered_time": "26",
  "billsec": "26",
  "missed": false,
  "answered_by_member": null,
  "answered_by_interface": null,
  "recorded": false,
  "recording_url": null,
  "recording_file": null,
  "channel_vars": {}
}

Ця подія структурно повторює call.ended (розділ 5.4) — ті самі поля duration, cause, cause_txt, recorded/recording_url/recording_file для запису розмови, якщо він увімкнений і для вихідних дзвінків. Додатково є два поля, специфічні для вихідного ланцюга:

Поля answered_by_member / answered_by_interface тут завжди null — вони стосуються виключно оператора черги на вхідному ланцюгу (розділ 5.4) і не мають значення для дзвінка, ініційованого співробітником напряму.

6. Отримання запису розмови

Файл запису не передається разом із веб-хуком. З огляду на обсяг аудіоданих, веб-хук містить лише посилання на файл, за яким CRM-система може звернутися окремим запитом у момент фактичної потреби (наприклад, при відкритті картки дзвінка оператором для прослуховування).

Посилання формується детерміновано на основі uniqueid і тому присутнє вже в першій події call.incoming, задовго до завершення дзвінка та фактичної появи файлу на диску.

Важливо щодо формату файлу. На стороні АТС записи спершу створюються у форматі WAV, після чого за розкладом (регулярне фонове завдання) конвертуються у MP3, а вихідний WAV-файл видаляється. Таким чином, на момент звернення CRM-системи за записом фактичний формат файлу заздалегідь невідомий і залежить від того, чи встигло відпрацювати завдання конвертації. Ендпоінт враховує це автоматично: сервер самостійно визначає, який файл існує на диску (.mp3 чи .wav), і повертає відповідні заголовки Content-Type (audio/mpeg або audio/wav) та Content-Disposition з іменем файлу, що містить фактичне розширення. CRM-системі не потрібно (і не рекомендується) вважати розширення фіксованим — коректна реалізація повинна визначати формат отриманого файлу за заголовком Content-Type відповіді, а не за самим URL чи заздалегідь заданим іменем файлу.

Для отримання файлу виконується запит GET із заголовком авторизації:

curl -H "Authorization: Token ВАШ_ТОКЕН" \
  https://pbx.example.com/api/v1/recordings/1753000000.42/ \
  -o call_recording

Токен видається адміністратором АТС одноразово, за аналогією з API-ключами інших сервісів, і підлягає зберіганню з тим самим рівнем захисту, що й пароль.

Можливі відповіді сервера:

Код відповіді Значення
200 або 206 з аудіофайлом запит виконано успішно; 206 повертається при запиті частини файлу (перемотка/стрімінг)
401 токен відсутній або некоректний — перевірте заголовок запиту
404 запис відсутній (дзвінок не записувався, або файл ще не встиг сформуватися на диску)

Якщо запит виконується одразу після call.ended і повертає 404, це не обов’язково свідчить про помилку: запис файлу на диск може відбуватися з незначною затримкою відносно надсилання JSON-повідомлення. У такому випадку рекомендується повторити запит через кілька секунд.

Для примусового завантаження файлу браузером (замість відтворення) додайте до URL параметр ?download=1.

7. REST API PearlPBX2

Окрім прийому веб-хуків, CRM-система може самостійно звертатися до REST API PearlPBX2 — зокрема для ініціалізації вихідних дзвінків, зведення кількох учасників в одну конференцію, а також (за потреби) для роботи зі списками номерів. Усі ендпоінти розташовані під префіксом /api/v1/ і потребують автентифікації.

7.1. Автентифікація

API використовує токен-автентифікацію. Токен передається в заголовку Authorization кожного запиту:

Authorization: Token ВАШ_ТОКЕН

Токен видається адміністратором АТС окремо від токена для завантаження записів розмов (розділ 6) — рекомендується уточнити у адміністратора, чи використовується спільний токен, чи для кожної цілі видається окремий. Запит без токена або з недійсним токеном повертає 401 Unauthorized.

7.2. Інтерактивна документація (Swagger / ReDoc)

Окрім цього документа, PearlPBX2 автоматично генерує машинозчитувану специфікацію API (на основі drf-spectacular) і надає до неї два готових веб-інтерфейси. Це зручно як довідник з актуальним переліком полів та як інструмент для ручного тестування запитів без написання коду:

Важливо щодо доступу. Ці сторінки не виключені із загальної вимоги автентифікації — так само, як і решта API, вони захищені токен-автентифікацією (див. розділ 7.1), а не автентифікацією через сесію Django. Це означає, що просте відкриття /api/v1/docs/ у браузері без додаткових дій поверне 401 Unauthorized, оскільки браузер не додає заголовок Authorization автоматично. Щоб скористатися Swagger UI, потрібен браузерний плагін або розширення, яке дозволяє додати заголовок Authorization: Token ВАШ_ТОКЕН до запитів сторінки, або перегляд специфікації через інструмент на кшталт Postman/Insomnia, де токен можна вказати в налаштуваннях запиту. Для одноразової перевірки специфікації без браузера достатньо звичайного запиту з токеном:

curl -H "Authorization: Token ВАШ_ТОКЕН" https://pbx.example.com/api/v1/schema/

Рекомендується звірятися з цими джерелами у разі сумнівів щодо актуальної версії API — вони генеруються безпосередньо з коду сервера і завжди відповідають поточному стану.

7.3. Ініціалізація вихідного дзвінка

POST /api/v1/calls/originate/

Ендпоінт ставить в чергу на виконання вихідний дзвінок через Asterisk Manager Interface (AMI). Типовий сценарій використання з боку CRM — дзвінок «у два кроки» (click-to-call): спершу викликається внутрішній номер оператора (channel), і лише після того, як оператор підніме слухавку, АТС з’єднує його з номером клієнта (exten).

Поля тіла запиту:

Поле Тип Обов’язкове Значення за замовчуванням Опис
channel рядок (до 256 символів) Так Канал, який АТС викликає першим, наприклад Local/0503856087@default або PJSIP/0504139380@mega-provider.
exten рядок (до 128 символів) Так Внутрішній номер або номер, з яким з’єднується канал channel після відповіді, наприклад 0675653380.
context рядок (до 128 символів) Ні "default" Контекст dialplan, у якому виконується з’єднання з exten (детальне пояснення — нижче).
priority ціле число Ні 1 Пріоритет dialplan (мінімальне значення — 1).
callerid рядок (до 128 символів) Ні Caller ID, який побачить викликана сторона, у форматі ім'я<номер>, наприклад 380443333333<0675653380>.
variable об’єкт (пари рядок → рядок) Ні Довільні канальні змінні Asterisk, наприклад {"userId": "0"}.
timeout_ms ціле число Ні 30000 Максимальний час очікування відповіді на виклик, у мілісекундах (від 1000 до 120000).

Що таке context у цьому запиті. Значення "default" у таблиці вище — лише приклад-заглушка, а не системна константа. Насправді context — це назва таблиці маршрутизації (RoutingTable) або контексту dialplan (DialplanContext), налаштованих адміністратором PearlPBX2 конкретно для цієї інсталяції в адмін-панелі. Назви таких контекстів довільні (наприклад, Incoming, Outgoing, internal-users) і не підпорядковуються жодній універсальній конвенції — кожна інсталяція АТС може мати власний набір назв залежно від того, скільки провайдерів, транків і сценаріїв маршрутизації налаштовано. Перед реалізацією інтеграції обов’язково уточніть у адміністратора АТС точні назви контекстів, які слід використовувати для ваших сценаріїв, і попросіть надати готові приклади запитів channel/exten/context саме для вашої інсталяції — самостійно вгадати ці значення неможливо.

Приклад 1: дзвінок «у два кроки» (click-to-call) через внутрішнього оператора.

Спершу АТС дзвонить на внутрішній номер оператора (channel), і лише після того, як оператор підніме слухавку, з’єднує його з номером клієнта (exten):

curl -X POST https://pbx.example.com/api/v1/calls/originate/ \
  -H "Authorization: Token ВАШ_ТОКЕН" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "Local/0503856087@default",
    "exten": "0675653380",
    "context": "default",
    "callerid": "380443333333<0675653380>",
    "variable": {"userId": "0"}
  }'

Приклад 2: прямий дзвінок через конкретний транк провайдера, без Local-каналу.

У деяких сценаріях Local-канал взагалі не потрібен: channel може одразу вказувати на реальний канал провайдера, а exten/context/priority — це точка dialplan, куди Asterisk приземлить цей канал одразу після того, як провайдер відповість на виклик. Це стандартна поведінка AMI-команди Originate — Local-канал потрібен лише тоді, коли перший «крок» сам по собі є внутрішнім номером (як у прикладі 1), а не тоді, коли channel уже є фінальним каналом виклику.

Наприклад: потрібно подзвонити напряму на номер 0504139380 через транк провайдера mega-provider, а результат (після відповіді) направити на внутрішній номер 222 у таблиці маршрутизації Incoming, підставивши CallerID 0442222222:

curl -X POST https://pbx.example.com/api/v1/calls/originate/ \
  -H "Authorization: Token ВАШ_ТОКЕН" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "PJSIP/0504139380@mega-provider",
    "exten": "222",
    "context": "Incoming",
    "priority": 1,
    "callerid": "0442222222<0442222222>",
    "timeout_ms": 30000
  }'

Тут PJSIP/0504139380@mega-provider означає «набрати номер 0504139380 через SIP-транк (peer) з іменем mega-provider»; mega-provider і Incoming — назви, які мають реально існувати в конкретній інсталяції АТС (узгоджуються з адміністратором, як описано вище).

Важливо про callerid у сценарії прямого виклику транка. На відміну від click-to-call через внутрішній номер (приклад 1), де CallerID зазвичай є лише інформаційним і може перезаписуватися логікою dialplan за замовчуванням, у сценарії прямого виклику транка (приклад 2) значення callerid реально передається на бік провайдера/мережі як номер, з якого нібито здійснюється виклик. Це не косметичне поле: якщо вказаний номер не належить вашому пулу номерів або не дозволений провайдером для підстановки, виклик може бути відхилений оператором зв’язку, позначений як спам/підозрілий, або, залежно від законодавства та політики провайдера, це може розглядатися як підміна номера (CLI spoofing). Перед використанням цього сценарію обов’язково узгодьте з адміністратором АТС і провайдером, які саме номери дозволено підставляти як CallerID для конкретного транка.

Успішна відповідь (200 OK):

{
  "status": "originated",
  "message": "Originate successfully queued"
}

Важливо: статус 200 та "status": "originated" означають лише те, що команду на встановлення з’єднання прийнято та передано в AMI. Це не є підтвердженням того, що виклик фактично відбувся чи що абонент відповів — цю інформацію CRM-система отримує окремо через веб-хуки (call.answered, call.ended), зіставляючи їх за uniqueid дзвінка, що виник у результаті originate.

Можливі помилки:

Код Тіло відповіді Причина
400 Bad Request {"channel": ["This field is required."]} (приклад для поля channel; аналогічно для будь-якого іншого обов’язкового поля) Не заповнене обов’язкове поле або порушено обмеження (наприклад, timeout_ms поза межами 1000120000).
401 Unauthorized {"detail": "Authentication credentials were not provided."} Відсутній або недійсний токен автентифікації.
502 Bad Gateway {"detail": "AMI unavailable."} АТС не змогла встановити або підтримати з’єднання з Asterisk Manager Interface.
502 Bad Gateway {"detail": "AMI originate timed out."} Відповідь від AMI не надійшла протягом timeout_ms (плюс службовий запас часу).
502 Bad Gateway {"detail": "Extension does not exist"} (приклад; текст відповідає повідомленню AMI) AMI повернула помилку виконання команди Originate — текст повідомлення береться безпосередньо з відповіді Asterisk і може відрізнятися залежно від причини відмови.
503 Service Unavailable {"detail": "Asterisk is disabled in this DEVMODE."} АТС працює в режимі розробки без підключення до реального Asterisk (лише на тестових стендах, у продакшн-середовищі не зустрічається).

Для цього ендпоінта не передбачено окремих обмежень на частоту запитів (rate limiting) — практичне обмеження визначається пропускною спроможністю самої лінії/транків АТС, тому CRM-системі рекомендується самостійно контролювати інтенсивність ініціалізації дзвінків відповідно до домовленостей з адміністратором АТС.

7.4. Ініціалізація конференції (три і більше учасники)

POST /api/v1/calls/conference/

Ендпоінт calls/originate/ (розділ 7.3) завжди з’єднує рівно двох учасників. Якщо потрібно звести в одну розмову трьох і більше осіб одночасно (наприклад, Оператора, Клієнта та Водія), використовується calls/conference/: ендпоінт приймає перелік каналів і заводить кожен із них у спільну конференц-кімнату на базі ConfBridge.

Модель конференції. Кімнати не потрібно створювати заздалегідь: кімната виникає в момент, коли до неї заходить перший канал, і зникає, коли виходить останній. Номер кімнати — довільний числовий рядок; усі учасники, приземлені на один і той самий номер, чують один одного.

Поля тіла запиту:

Поле Тип Обов’язкове Значення за замовчуванням Опис
parties список рядків (мінімум 2) Так Канали учасників, наприклад ["PJSIP/101", "PJSIP/0504139380@mega-provider", "Local/2222@internal"].
room рядок (до 64 символів) Ні генерується автоматично Номер конференц-кімнати. Якщо не вказано, сервер згенерує новий і поверне його у відповіді.
context рядок (до 128 символів) Ні контекст конференції, налаштований на АТС Дialplan-контекст, що приземляє кожне плече в ConfBridge. Для більшості інтеграцій змінювати не потрібно.
callerid рядок (до 128 символів) Ні Caller ID, застосований до кожного з originate-плечей.
timeout_ms ціле число Ні 30000 Максимальний час очікування відповіді на кожне плече, у мілісекундах (від 1000 до 120000).

Як і в прикладі з водієм у розділі 7.3, канал, що має вести до внутрішнього номера з підтримкою декількох пристроїв/фолбеку, варто вказувати через Local-канал (наприклад, Local/2222@internal), а не напряму — це дозволяє dialplan-логіці (кілька пристроїв, follow-me) самій вирішити, куди зрештою потрапить виклик.

Приклад запиту (Оператор, Клієнт через транк провайдера, Водій через внутрішній номер з фолбеком):

curl -X POST https://pbx.example.com/api/v1/calls/conference/ \
  -H "Authorization: Token ВАШ_ТОКЕН" \
  -H "Content-Type: application/json" \
  -d '{
    "parties": [
      "PJSIP/101",
      "PJSIP/0504139380@mega-provider",
      "Local/2222@internal"
    ]
  }'

Відповідь (202 Accepted):

{
  "room": "184920573",
  "results": [
    {"channel": "PJSIP/101", "queued": true, "detail": "Originate successfully queued"},
    {"channel": "PJSIP/0504139380@mega-provider", "queued": true, "detail": "Originate successfully queued"},
    {"channel": "Local/2222@internal", "queued": true, "detail": "Originate successfully queued"}
  ]
}

Код 202 і "queued": true означають лише постановку відповідної команди Originate в чергу AMI — усі плечі набираються паралельно, а не по черзі.

Це не підтвердження відповіді чи встановлення розмови: цю інформацію CRM отримує окремо через веб-хуки (call.answered, call.ended) для кожного плеча, за власним uniqueid.

Частковий збій можливий: якщо одне плече не додзвонилось, а решта приєдналися успішно, відповідний елемент results матиме "queued": false з поясненням у detail, а решта — "queued": true.

Можливі помилки:

Код Тіло відповіді Причина
400 Bad Request {"parties": ["Ensure this field has at least 2 elements."]} Передано менше двох учасників, або порушено інше обмеження полів.
401 Unauthorized {"detail": "Authentication credentials were not provided."} Відсутній або недійсний токен автентифікації.
502 Bad Gateway {"detail": "AMI unavailable."} АТС не змогла встановити з’єднання з Asterisk Manager Interface (жодне плече не було поставлено в чергу).
503 Service Unavailable {"detail": "Asterisk is disabled in this DEVMODE."} АТС працює в режимі розробки без підключення до реального Asterisk.

7.5. Інші ендпоінти

API також надає ендпоінти для роботи зі списками номерів. Вони не є обов’язковими для базової інтеграції (прийом подій дзвінків та ініціалізація вихідних дзвінків), але можуть бути корисними, якщо CRM-система бере на себе керування чорними/білими списками чи довідником контактів.

Ендпоінт Методи Призначення
/api/v1/blacklist/, /api/v1/blacklist/{id}/ GET, POST, PUT, PATCH, DELETE Керування списком заблокованих номерів (callerid + destination). Повторний POST з тими самими callerid/destination оновлює наявний запис (200), новий запис створюється зі статусом 201.
/api/v1/whitelist/, /api/v1/whitelist/{id}/ GET, POST, PUT, PATCH, DELETE Аналогічно до blacklist/, але для дозволених номерів.
/api/v1/contacts/, /api/v1/contacts/{id}/ GET, POST, PUT, PATCH, DELETE Довідник відповідності callerid → ім’я контакту.
/api/v1/lists/, /api/v1/lists/{id}/ GET, POST, PUT, PATCH, DELETE Керування довільними іменованими списками.
/api/v1/lists/{id}/entries/ GET, POST Перегляд і додавання записів у межах конкретного списку.
/api/v1/lists/{id}/entries/{entry_id}/ DELETE Видалення окремого запису зі списку.
/api/v1/recordings/{uniqueid}/ GET Отримання аудіозапису дзвінка (детально описано в розділі 6).

Усі перелічені ендпоінти для списків підтримують стандартну пагінацію (за замовчуванням 50 записів на сторінку) та повертають типові для DRF помилки валідації.

7.6. Формат помилок

Помилки валідації запиту повертаються у стандартному форматі Django REST Framework — об’єкт, де ключ відповідає назві поля, а значення — список текстових повідомлень:

{
  "callerid": ["This field is required."]
}

Помилки, не прив’язані до конкретного поля (наприклад, відмова в автентифікації чи відсутність ресурсу), повертаються у полі detail:

{
  "detail": "Authentication credentials were not provided."
}

Спроба створити ресурс, що порушує обмеження унікальності на рівні бази даних, повертає 409 Conflict:

{
  "detail": "Resource already exists or violates a uniqueness constraint."
}

Рекомендується, щоб обробник відповідей CRM-системи орієнтувався насамперед на код HTTP-статусу, а вміст поля detail/назв полів використовував для діагностики та логування.

8. Dashboard API та WebSocket: канал реального часу (опційно)

Окрім веб-хуків та REST API (розділи 1–7), для деяких інсталяцій PearlPBX2 адміністратор може додатково надати доступ до внутрішнього API операторського дашборду. Це опційний, альтернативний канал — переважній більшості інтеграцій достатньо веб-хуків і REST API, описаних вище. Розділ призначений для випадків, коли CRM-системі потрібен саме поточний знімок стану АТС (черги, канали, активні дзвінки) чи неперервний потік подій, а не лише повідомлення про ключові моменти дзвінка.

Принципова відмінність від решти документа: формат даних цього каналу — внутрішній формат операторського дашборду PearlPBX2, а не стабілізований контракт інтеграції. Набір полів і типів подій може змінюватися між версіями без окремого циклу узгодження. Якщо є вибір — веб-хуки (розділ 5) є пріоритетним і рекомендованим джерелом даних для CRM.

8.1. Автентифікація

Обидва механізми нижче приймають той самий токен доступу, що видається адміністратором АТС за схемою токен-автентифікації Django REST Framework (та сама схема, що й у розділі 7.1; уточніть у адміністратора, чи використовується спільний токен з REST API, чи видається окремий).

8.2. Dashboard API (GET /dashboard/api/...)

Ендпоінти лише для читання, кожен приймає токен у заголовку Authorization: Token ВАШ_ТОКЕН (сесія Django теж підходить, але для CRM-інтеграції актуальний саме токен):

Ендпоінт Призначення
GET /dashboard/api/queues/ Стан усіх черг обслуговування (учасники, дзвінки, статистика).
GET /dashboard/api/queues/{queue_name}/ Стан конкретної черги.
GET /dashboard/api/channels/ Стан усіх активних каналів Asterisk.
GET /dashboard/api/channels/{channel_name}/ Стан конкретного каналу.
GET /dashboard/api/channels/type/{channel_type}/ Канали, відфільтровані за типом (PJSIP, Local тощо).
GET /dashboard/api/calls/active/ Активні (з’єднані) дзвінки з інформацією про обидва плеча.
GET /dashboard/api/endpoints/ Перелік внутрішніх SIP-користувачів і зовнішніх SIP-транків, налаштованих на АТС.
GET /dashboard/api/missed-calls/?queue={назва} Пропущені за сьогодні дзвінки в конкретній черзі.

Приклад запиту:

curl -H "Authorization: Token ВАШ_ТОКЕН" \
  https://pbx.example.com/dashboard/api/queues/

Запит без токена або з недійсним токеном повертає 401 Unauthorized.

Важливо: дві дії дашборду токеном не відкриваються. POST /dashboard/api/channels/hangup/ (примусове завершення дзвінка) і POST /dashboard/api/queues/pause/ (постановка/зняття оператора з паузи) — це керуючі дії, що напряму викликають Asterisk Manager Interface. Вони навмисно лишаються доступними лише через сесію Django зі статусом персоналу (is_staff) та CSRF-захистом і не приймають токен інтеграції. Токен, виданий для читання, не дає можливості керувати живими дзвінками чи чергами.

8.3. WebSocket /ws/asterisk/ — потік подій у реальному часі

wss://pbx.example.com/ws/asterisk/?token=ВАШ_ТОКЕН — той самий потік подій, що живить операторський дашборд: кожне повідомлення надходить одразу після відповідної події Asterisk, без опитування (polling). З’єднання лише для читання — сервер не приймає жодних команд від клієнта в межах цього сокета.

Формат кожного повідомлення:

{
  "type": "channel_new",
  "data": { "channel": "PJSIP/101-0000001a", "uniqueid": "1753000000.42", "..." : "..." },
  "timestamp": "2026-07-24T14:32:10.123456"
}

type набуває значень на кшталт channel_new, channel_state_change, channel_dial_begin, channel_dial_end, channel_hangup, queue_caller_join, queue_caller_leave, queue_caller_abandon, queue_member_status, agent_connect та інших — це набагато детальніший і нижчорівневий потік, ніж чотири події веб-хука з розділу 5, і призначений радше для live-відображення стану АТС (наприклад, дошки диспетчера), ніж для бізнес-логіки на кшталт створення задачі в CRM. Для типової інтеграції (картка дзвінка, зворотний дзвінок за пропущеним викликом, прикріплення запису) веб-хуки з розділу 5 лишаються правильним і достатнім джерелом.

Браузерний WebSocket API не дозволяє додавати кастомні заголовки, тому токен передається саме через query-параметр ?token=; для нативних (не браузерних) клієнтів так само приймається заголовок Authorization: Token ВАШ_ТОКЕН. Без коректного токена і без активної сесії Django з’єднання одразу закривається сервером.

9. Перевірка автентичності запитів

Для запобігання прийому підроблених запитів, що імітують веб-хук PearlPBX2, передбачено механізм цифрового підпису.

За умови налаштування секретного ключа адміністратором АТС кожен запит супроводжується заголовком:

X-PearlPBX-Signature: sha256=abc123...

Значення заголовка є HMAC-SHA256-підписом тіла запиту, обчисленим із використанням секретного ключа, відомого обом сторонам (АТС і серверу CRM). Перевірка підпису підтверджує, що запит дійсно надіслано PearlPBX2 і його вміст не було змінено під час передачі.

Приклад перевірки на Python:

import hashlib
import hmac

SECRET = "секретний-ключ-з-налаштувань-атс"

def is_signature_valid(raw_body: bytes, header_value: str) -> bool:
    expected = "sha256=" + hmac.new(
        SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    # Порівняння виконується з константним часом; пряме порівняння рядків неприпустиме
    return hmac.compare_digest(expected, header_value)

Аналогічний приклад на Node.js:

const crypto = require("crypto");

const SECRET = "секретний-ключ-з-налаштувань-атс";

function isSignatureValid(rawBody, headerValue) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headerValue));
}

Важливе застереження: підпис обчислюється на основі сирих байтів тіла запиту, до будь-якого парсингу JSON. Якщо використовуваний веб-фреймворк автоматично парсить JSON до отримання доступу до сирого тіла запиту, необхідно забезпечити доступ саме до вихідних байтів (у Flask — метод request.get_data(), в Express — middleware express.raw() або еквівалентний механізм збереження сирого буфера).

Якщо секретний ключ не налаштовано, заголовок підпису буде відсутній — це є штатною поведінкою, що означає відсутність перевірки підпису. Рекомендується, тим не менш, налаштувати цей механізм з міркувань безпеки.

10. Налаштування власного формату JSON

У випадку, якщо CRM-система очікує тіло запиту іншої структури (з відмінними назвами полів або додатковою вкладеністю), адміністратор АТС може налаштувати власний шаблон тіла запиту в адміністративній панелі, де стандартні поля замінюються довільними з підстановкою значень через синтаксис ${назва_змінної}. Приклад:

{
  "call_id": "${uniqueid}",
  "customer_phone": "${caller_id_num}",
  "type": "phone_call",
  "recording": "${recording_url}"
}

Перелік усіх доступних змінних для підстановки: event, uniqueid, linkedid, channel, caller_id_num, caller_id_name, exten, context, queue, timestamp, duration, cause, cause_txt, answered_time, billsec, recorded, recording_expected, recording_url, recording_file, missed, wait_time, member_name, member_interface, member_number, ringtime, holdtime, answered_by_member, answered_by_interface, direction, dest_channel, dial_status, answered, channel_vars.

channel_vars — об’єкт із дозволеними адміністратором АТС змінними каналу Asterisk (наприклад, {"ULINE": "42"}); порожній об’єкт {}, якщо жодна ще не встановлена. У шаблоні ${channel_vars} як єдиний вміст рядкового поля підставляється вкладеним JSON-об’єктом, а не текстом.

Налаштування шаблону виконується виключно на стороні адміністратора АТС і не потребує участі розробника CRM. За потреби зміни стандартного формату достатньо звернутися до адміністратора з відповідним запитом. Під час створення нового веб-хука поле шаблону в адміністративній панелі вже заповнене прикладом з переліком усіх доступних змінних — редагування зводиться до видалення зайвих рядків.

11. Поведінка системи при збоях доставки

Механізм доставки веб-хуків побудований за принципом «найкращого зусилля» (best-effort):

Практичні наслідки для реалізації CRM-системи:

12. Приклад реалізації сервера-приймача

Нижче наведено приклад реалізації на Python (Flask), що приймає події обох ланцюгів (вхідного та вихідного), виконує перевірку підпису та завантажує файл запису за умови його наявності. Приклад може використовуватися як основа для власної реалізації.

import hashlib
import hmac
import os

import requests
from flask import Flask, request, abort

app = Flask(__name__)

# Секретний ключ для перевірки підпису веб-хука, узгоджений з адміністратором АТС
WEBHOOK_SECRET = os.environ["PEARLPBX_WEBHOOK_SECRET"]
# Токен для завантаження записів розмов через API
API_TOKEN = os.environ["PEARLPBX_API_TOKEN"]


def is_signature_valid(raw_body: bytes, header_value: str | None) -> bool:
    if not header_value:
        return False
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header_value)


@app.post("/pearlpbx/webhook")
def handle_webhook():
    raw_body = request.get_data()  # сирі байти, до парсингу JSON

    if not is_signature_valid(raw_body, request.headers.get("X-PearlPBX-Signature")):
        abort(401)

    payload = request.get_json()
    event = payload["event"]
    call_id = payload["uniqueid"]

    if event == "call.incoming":
        open_live_call_card(call_id, payload["caller_id_num"], payload.get("caller_id_name"))

    elif event == "call.answered":
        mark_call_answered(call_id, payload["member_name"])

    elif event == "call.missed":
        create_callback_task(call_id, payload["caller_id_num"], payload["wait_time"])

    elif event == "call.ended":
        close_call_card(call_id, duration=payload["duration"])
        if payload.get("recorded"):
            download_recording(call_id, payload["recording_url"])

    elif event == "call.outgoing":
        open_live_call_card(call_id, payload["exten"], payload.get("caller_id_name"))

    elif event == "call.outgoing_answered":
        mark_call_answered(call_id, payload["caller_id_name"])

    elif event == "call.outgoing_ended":
        close_call_card(call_id, duration=payload["duration"])
        if payload.get("recorded"):
            download_recording(call_id, payload["recording_url"])

    return "", 200


def download_recording(call_id: str, recording_url: str) -> None:
    response = requests.get(
        recording_url,
        headers={"Authorization": f"Token {API_TOKEN}"},
        timeout=10,
    )
    if response.status_code == 404:
        # Файл ще не сформовано на диску; повторна спроба можлива пізніше
        return
    response.raise_for_status()

    # Реальний формат файлу (wav чи mp3) визначається за Content-Type,
    # оскільки на боці АТС записи конвертуються з wav у mp3 за розкладом
    extension = "mp3" if response.headers.get("Content-Type") == "audio/mpeg" else "wav"
    with open(f"/data/recordings/{call_id}.{extension}", "wb") as f:
        f.write(response.content)


def open_live_call_card(call_id, phone, name):
    print(f"[incoming] {call_id}: виклик від {name or phone}")


def mark_call_answered(call_id, member_name):
    print(f"[answered] {call_id}: відповів {member_name}")


def create_callback_task(call_id, phone, wait_time):
    print(f"[missed] {call_id}: {phone}, очікування {wait_time}с, потрібен зворотний дзвінок")


def close_call_card(call_id, duration):
    print(f"[ended] {call_id}: тривалість {duration}с")

Аналогічна реалізація на Node.js (Express):

const express = require("express");
const crypto = require("crypto");

const app = express();
const WEBHOOK_SECRET = process.env.PEARLPBX_WEBHOOK_SECRET;
const API_TOKEN = process.env.PEARLPBX_API_TOKEN;

// Збереження сирих байтів тіла запиту для перевірки підпису
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

function isSignatureValid(rawBody, headerValue) {
  if (!headerValue) return false;
  const expected =
    "sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headerValue));
}

app.post("/pearlpbx/webhook", async (req, res) => {
  if (!isSignatureValid(req.rawBody, req.headers["x-pearlpbx-signature"])) {
    return res.sendStatus(401);
  }

  const payload = req.body;

  switch (payload.event) {
    case "call.incoming":
      console.log(`[incoming] ${payload.uniqueid}: виклик від ${payload.caller_id_num}`);
      break;
    case "call.answered":
      console.log(`[answered] ${payload.uniqueid}: відповів ${payload.member_name}`);
      break;
    case "call.missed":
      console.log(`[missed] ${payload.uniqueid}: очікування ${payload.wait_time}с`);
      break;
    case "call.ended":
      console.log(`[ended] ${payload.uniqueid}: тривалість ${payload.duration}с`);
      if (payload.recorded) {
        await downloadRecording(payload.uniqueid, payload.recording_url);
      }
      break;
    case "call.outgoing":
      console.log(`[outgoing] ${payload.uniqueid}: дзвінок на ${payload.exten}`);
      break;
    case "call.outgoing_answered":
      console.log(`[outgoing_answered] ${payload.uniqueid}: відповіли`);
      break;
    case "call.outgoing_ended":
      console.log(`[outgoing_ended] ${payload.uniqueid}: answered=${payload.answered}`);
      if (payload.recorded) {
        await downloadRecording(payload.uniqueid, payload.recording_url);
      }
      break;
  }

  res.sendStatus(200);
});

async function downloadRecording(callId, recordingUrl) {
  const response = await fetch(recordingUrl, {
    headers: { Authorization: `Token ${API_TOKEN}` },
  });
  if (response.status === 404) return; // файл ще не сформовано, спроба пізніше
  if (!response.ok) throw new Error(`Recording fetch failed: ${response.status}`);

  // Реальний формат файлу визначається за Content-Type: записи конвертуються
  // з wav у mp3 за розкладом на боці АТС, тому розширення заздалегідь не фіксоване
  const extension = response.headers.get("Content-Type") === "audio/mpeg" ? "mp3" : "wav";
  const buffer = Buffer.from(await response.arrayBuffer());
  require("fs").writeFileSync(`/data/recordings/${callId}.${extension}`, buffer);
}

app.listen(3000, () => console.log("Webhook receiver listening on :3000"));

13. Питання, що часто виникають

Чи можливе отримання call.ended без попереднього call.incoming? Ні. Система відстежує цей стан внутрішньо: подія завершення надсилається виключно для дзвінків, про початок яких уже було повідомлено. Наявність call.ended гарантує, що раніше надходила подія call.incoming з тим самим uniqueid.

Чи можливі call.missed або call.answered без попереднього call.incoming? Так. Обидві події є самостійними: пропущений виклик і факт відповіді оператора розглядаються як достатньо значущі для надсилання незалежно від підписки на подію початку дзвінка.

Чому подія call.answered відсутня для прямих дзвінків без черги? Ця подія відповідає AMI-події Asterisk AgentConnect, яка формується виключно для дзвінків, що пройшли через чергу обслуговування. Прямий виклик конкретному співробітнику без участі черги цю подію не ініціює — це є очікуваною поведінкою.

Що означає значення null у полі queue? Дзвінок здійснювався без участі черги обслуговування (наприклад, прямий виклик конкретному співробітнику). Це не є помилкою.

Чи є uniqueid стабільним ідентифікатором у часі? Так, у межах одного дзвінка uniqueid є стабільним і унікальним ідентифікатором, придатним для використання як первинний ключ при зіставленні подій.

Чи є значення recording_expected: null у call.incoming помилкою? Ні. Це означає, що на момент початку дзвінка система ще не визначила, чи буде здійснено запис. Остаточне значення міститься в полі recorded події call.ended.

Чи потрібна окрема логіка для дзвінків без запису? Достатньо перевіряти умову recorded === true перед зверненням за файлом запису. За значень false або null звернення за посиланням поверне 404.

Яка поведінка передбачена у разі тимчасової недоступності сервера CRM під час надсилання події? Система виконає кілька повторних спроб доставки автоматично. Якщо жодна зі спроб не буде успішною, подія не надсилається повторно. Це слід враховувати при плануванні надійності роботи власного ендпоінта (зокрема, моніторингу його доступності).

Чи означає успішна відповідь POST /api/v1/calls/originate/ (200 OK), що абонент відповів на дзвінок? Ні. Успішна відповідь підтверджує лише постановку команди Originate в чергу на виконання в Asterisk Manager Interface. Фактичний перебіг дзвінка (відповідь, тривалість, завершення) відстежується виключно через відповідні події веб-хука (call.answered, call.ended), зіставлені за uniqueid цього дзвінка.

Чому запит на ініціалізацію дзвінка повернув 502 Bad Gateway? Це означає помилку на рівні взаємодії АТС з Asterisk Manager Interface — саме Asterisk, а не CRM-система, є джерелом проблеми. Причина конкретизується в полі detail відповіді (див. розділ 7.2): відсутність з’єднання з AMI, перевищення часу очікування (timeout_ms) або відмова Asterisk виконати команду (наприклад, неіснуючий внутрішній номер чи контекст).

Чим call.outgoing відрізняється від call.incoming, якщо обидва означають «дзвінок почався»? Напрямком ініціації. call.incoming — дзвінок надходить до підприємства (від клієнта через транк, або внутрішній дзвінок у контекст/чергу). call.outgoing — дзвінок ініціює співробітник, набираючи номер зі свого телефону. Це два повністю незалежні ланцюги подій з різними назвами на кожному кроці (call.ended проти call.outgoing_ended), і жоден дзвінок не породжує події з обох ланцюгів одночасно.

Чи може подія вихідного ланцюга (call.outgoing*) прийти для дзвінка, що насправді йде через транк (з’єднання з провайдером)? Ні, за жодних обставин. Система визначає ініціатора дзвінка за технічним ідентифікатором каналу конкретного співробітника, а не за напрямком чи назвою маршруту — навіть якщо адміністратор налаштував транк і внутрішніх користувачів на один і той самий маршрут, дзвінки транка це не переплутає з дзвінками співробітників.

Чи прийде call.outgoing_ended без попереднього call.outgoing_answered, якщо лінія була зайнята? Так, і це штатна поведінка. call.outgoing_answered надходить, лише якщо викликана сторона підняла слухавку. Якщо лінія зайнята, ніхто не відповів, або дзвінок скасовано — одразу надходить call.outgoing_ended з полем answered: false і кодом причини в dial_status.

14. Контрольний список інтеграції

Виконання наведених пунктів є достатнім для повноцінної реалізації інтеграції.