REST API своей АТС
Для разработчика CRM, учётной системы или своей панели: REST API читает звонки и записи, запускает звонок с телефона сотрудника и управляет настройками АТС. Владелец создаёт ключ с нужными правами — интеграция действует от его имени и только в пределах этих прав. Ключи, права, OAuth и лимиты — в обзоре API и MCP своей АТС.
В статье: адрес и ключ, ошибки, журнал, звонок в клик, добавочные, метрики, правила, конфигурация, события, открыто и закрыто, сценарии.
Адрес и ключ#
Адрес API — https://api.cenaly.ru/pbx-api/v1. Ключ передаётся в заголовке Authorization: Bearer cpx_… или X-Api-Key: cpx_…. Не передавайте ключ в адресе запроса и не кладите его в браузерный JavaScript.
BASE='https://api.cenaly.ru/pbx-api/v1'
KEY='cpx_…'
curl -sS "$BASE/me" -H "Authorization: Bearer $KEY"
Ключ создаёт владелец: Телефония → API и MCP → Ключи → Создать ключ. Секрет показывается один раз; храните его в менеджере секретов. В примерах вместо секрета — заглушка cpx_….
| Набор | Права |
|---|---|
| CRM | calls:read, recordings:read, calls:dial, extensions:read |
| ИИ-ассистент | CRM + extensions:write, presence:read |
| Только чтение | calls:read, recordings:read, extensions:read, routes:read, presence:read, reports:read |
| Всё | все обычные права, кроме пяти прав отдельного согласия |
Права routes:write, config:export, config:import, webhooks:write, daynight:write не входят ни в один набор — отмечайте их отдельно, если они нужны.
location — id точки из /locations. Для GET он передаётся в query, для POST/PUT/PATCH — в JSON или query. Если ключу доступна ровно одна точка, параметр можно опустить; иначе ответ 400 location_required перечислит доступные точки. В интеграции лучше передавать location всегда: у ключа может появиться вторая точка.
Спецификация: OpenAPI 3.1; описание с примерами — api.cenaly.ru/pbx-api/v1/docs. Примеры ответов в этой статье составлены по коду на вымышленных данных; необязательные поля зависят от данных и настроек АТС.
Лимиты и ошибки#
120 запросов в минуту и 20 000 в сутки на ключ или подключение — общий бюджет REST и MCP. Звонок в клик дополнительно ограничен: 10 в минуту и 300 в сутки. Журнал отдаёт до 100 звонков за запрос, лента событий — до 500.
Ошибка — JSON с полем error, например {"error":"scope_required","scope":"calls:read"}. Не рассчитывайте на поле message.
| HTTP | Примеры error |
Что делать |
|---|---|---|
| 400 | invalid_json, location_required, time_invalid, cursor_expired |
исправить JSON или параметры; при истёкшем курсоре начать выборку заново |
| 401 | api_key_missing, api_key_invalid |
проверить ключ, срок и отзыв; токен OAuth — обновить |
| 403 | scope_required, locations_outside_key |
выдать право; сузить точки вебхука до точек ключа |
| 404 | not_found, location_not_found, call_not_found, recording_not_found |
проверить путь, id, доступ ключа к точке и окно хранения |
| 409 | rev_conflict, transcript_in_progress, daynight_code_off |
перечитать состояние; дождаться расшифровки; включить закрытие кодом *28 |
| 429 | rate_limited |
подождать Retry-After секунд (60) |
| 429 | dial_rate_limited |
в теле ответа retryAfterSec: 60; заголовка Retry-After у этой ошибки нет |
| 503 | auth_unavailable |
временный сбой проверки ключа — повторить позже |
Не повторяйте запуск звонка автоматически после сетевого обрыва с неизвестным исходом: звонок мог уже начаться. Отдельного ключа идемпотентности у /dial нет.
Ключ и точки#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /me |
любой ключ | — | key{id,name,hint,scopes,locations,expiresAt?}, limits, direct? |
| GET | /locations |
любой ключ | — | items[]{id,name,pbx,direct?}; pbx может быть null |
curl -sS "$BASE/me" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/locations" -H "Authorization: Bearer $KEY"
Ответ /me для ключа на все точки (locations: [] — все точки, включая будущие):
{"key":{"id":"pk_0123456789ab","name":"CRM","hint":"cpx_exampl","scopes":["calls:read"],"locations":[]},"limits":{"perMinute":120,"perDay":20000,"dialsPerMinute":10,"dialsPerDay":300},"direct":{"loc1":{"ready":false,"reason":"no_feature"}}}
Пример /locations для точки без настроенной АТС:
{"items":[{"id":"loc1","name":"Главная точка","pbx":null}]}
Прямой адрес АТС — готовится#
У точки со своей АТС запросы смогут идти прямо на сервер АТС, минуя облако. Поле direct в /me (карта по id точки) и в элементах /locations уже показывает, готов ли такой адрес: ready, reason, а когда имя сервера известно — rest и mcp. Пока эта возможность внедряется, ready равен false: работайте с адресом https://api.cenaly.ru/pbx-api/v1. Он остаётся рабочим и после появления прямого адреса — как запасной путь. Подключениям по OAuth поле direct не выдаётся.
Журнал звонков#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /calls |
calls:read |
location, since, until, direction, status, number, extension, limit, cursor |
location, items, nextCursor?, window{maxCalls,maxDays,full} |
| GET | /calls/{id} |
calls:read |
location, id в пути |
location, call с steps[] |
| GET | /calls/{id}/recording |
recordings:read |
location, id |
callId,url,expiresIn,format |
| GET | /calls/{id}/transcript |
recordings:read |
location, id |
callId,recId,language?,piiRedacted?,text,segments[],summary? |
since — включительно, until — исключительно: ISO-время со смещением удобно для местного дня. Направления — inbound, outbound, internal, transfer. Статус — точное значение или группа: answered включает forwarded; missed — missed, busy, cancelled, abandoned; ai — bot, secretary, voicemail. number ищет часть цифр номера, extension — добавочный стороны или ответившего, а не всех телефонов, которым звонили.
По умолчанию limit=50, максимум 100; новые звонки первыми. Передавайте полученный nextCursor без изменений и с теми же фильтрами, пока поле не исчезнет.
curl -sS -G "$BASE/calls" -H "Authorization: Bearer $KEY" \
--data-urlencode 'location=loc1' --data-urlencode 'status=missed' \
--data-urlencode 'since=2026-10-23T00:00:00+04:00' \
--data-urlencode 'until=2026-10-24T00:00:00+04:00' --data-urlencode 'limit=100'
{"location":"loc1","items":[{"id":"1792742400.1","startedAt":"2026-10-23T08:00:00Z","endedAt":"2026-10-23T08:00:20Z","direction":"inbound","status":"missed","statusGroup":"missed","from":{"number":"+995555123456"},"to":{"extension":"101"},"durationSec":20}],"window":{"maxCalls":400,"maxDays":90,"full":false}}
API видит последние 400 звонков точки за 90 дней (window); full:true значит, что окно заполнено и старые звонки уже не видны. Нужен полный архив — сохраняйте журнал у себя.
curl -sS "$BASE/calls/1792742400.1?location=loc1" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/calls/1792742400.2/recording?location=loc1" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/calls/1792742400.2/transcript?location=loc1" -H "Authorization: Bearer $KEY"
{"location":"loc1","call":{"id":"1792742400.1","startedAt":"2026-10-23T08:00:00Z","endedAt":"2026-10-23T08:00:20Z","direction":"inbound","status":"missed","statusGroup":"missed","from":{"number":"+995555123456"},"to":{"extension":"101"},"durationSec":20,"steps":[]}}
Ответ на запрос записи:
{"callId":"1792742400.2","url":"https://example.com/temporary-audio","expiresIn":600,"format":"wav"}
URL здесь — заглушка; используйте ровно выданную ссылку. Она временная — 10 минут, точный срок в expiresIn. Расшифровка:
{"callId":"1792742400.2","recId":"rec-example","language":"ru","text":"Здравствуйте.","segments":[{"speaker":"1","start":0,"end":1.2,"text":"Здравствуйте."}]}
404 transcript_not_ready и 409 transcript_in_progress — разные стадии: расшифровки ещё нет или она идёт прямо сейчас. summary необязателен — не требуйте его для показа текста.
Звонок в клик#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| POST | /dial |
calls:dial |
location, extension, number |
commandId,location,extension,number,e164?,state,fast |
| GET | /dial/{commandId} |
calls:dial |
location |
commandId,state,stage,reason? |
Сначала звонит телефон сотрудника, после его ответа АТС набирает внешний номер. Разрешения исходящих и ограничения точки продолжают действовать.
curl -sS "$BASE/dial" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","extension":"101","number":"+995555123456"}'
curl -sS "$BASE/dial/cb-0123456789abcdef0123?location=loc1" -H "Authorization: Bearer $KEY"
{"commandId":"cb-0123456789abcdef0123","location":"loc1","extension":"101","number":"+995555123456","state":"queued","fast":true}
state:"queued" — команда принята, а не разговор состоялся. fast:false предупреждает, что первый звонок может ждать связи с АТС до минуты; следующие несколько минут команды уходят за секунды. Ход звонка — по commandId:
stage |
Что происходит |
|---|---|
queued |
команда ждёт АТС |
sent |
АТС забрала команду |
calling_you |
звонит телефон сотрудника |
dialing_client |
сотрудник ответил, АТС набирает клиента |
connected |
клиент ответил |
ended |
звонок завершён |
failed |
не получилось; причина — в reason |
{"commandId":"cb-0123456789abcdef0123","state":"ringing","stage":"dialing_client"}
Итог разговора сверяйте с журналом или событиями.
Добавочные, переадресация, DND и присутствие#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /extensions |
extensions:read |
location |
location,items[]{extension,kind,name,enabled,forwarding?,dndRequested?,presence?,external?} |
| PUT | /extensions/{ext}/forwarding |
extensions:write |
location, forward |
location,extension,forwarding,supported? |
| PUT | /extensions/{ext}/dnd |
extensions:write |
location, on, until? |
location,extension,dnd |
| GET | /presence |
presence:read |
location |
location,live,items |
kind — person, group или room. presence в списке добавочных появляется только при праве presence:read. live:false предупреждает, что сведения не живое состояние АТС (например, она давно не присылала снимок).
curl -sS "$BASE/extensions?location=loc1" -H "Authorization: Bearer $KEY"
curl -sS -X PUT "$BASE/extensions/102/forwarding" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","forward":{"always":{"kind":"extension","extension":"101"}}}'
curl -sS -X PUT "$BASE/extensions/102/dnd" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","on":true,"until":"morning"}'
curl -sS "$BASE/presence?location=loc1" -H "Authorization: Bearer $KEY"
{"location":"loc1","items":[{"extension":"101","kind":"person","name":"Анна","enabled":true,"forwarding":null}]}
{"location":"loc1","extension":"102","forwarding":{"always":{"kind":"extension","extension":"101"}},"supported":true}
Пустой список присутствия:
{"location":"loc1","live":true,"items":[]}
В forward допустимы условия always, busy, noAnswer, offline, dnd, offShift и число noAnswerSec. Цель — {kind:"extension",extension}, {kind:"number",number} или {kind:"voicemail"}. forward:null снимает переадресацию. Передавайте весь желаемый объект: это не частичное изменение.
Ответ снятия DND, пока АТС ещё применяет запрос:
{"location":"loc1","extension":"102","dnd":{"on":true,"requested":{"on":false,"at":1792836000},"appliedAt":1792835900,"pending":true,"supported":true,"until":null,"timer":true}}
DND: on:false снимает режим; until при включении — Unix-секунды или "morning" (начало следующей смены, иначе 08:00 местного времени), АТС снимет режим сама. dnd показывает и запрос, и его применение: не сообщайте пользователю «готово», пока pending:true.
Метрики#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /metrics |
reports:read |
location, from, to, compare, sections, callbacks |
location,tz,today,history,range,coverage,sl, выбранные разделы, compare, compareSkipped?, callbacks? |
from/to — местные даты точки YYYY-MM-DD включительно; без них — последние 7 дней. compare: prev, year, prev,year или пусто. Разделы: metrics,loss,queues,lines,operators,directions,heatmap,daily; heatmap и daily приходят только по явному запросу. callbacks=true добавляет скорость перезвона (для периодов до 92 дней). Сравнение с прошлым годом — для периодов до 400 дней.
curl -sS "$BASE/metrics?location=loc1&from=2026-09-01&to=2026-09-30&compare=prev,year" -H "Authorization: Bearer $KEY"
Пример периода без данных:
{"location":"loc1","tz":"Asia/Tbilisi","today":"2026-10-24","history":{"since":null,"gaps":[]},"range":{"from":"2026-10-23","to":"2026-10-23"},"coverage":{"periodDays":1,"days":0,"v2Days":0},"sl":{"slaSec":20,"targetPct":80,"shortAbandonSec":5},"queues":[],"compare":{}}
Показатели лежат в поле metrics в корне ответа — те же цифры, что на экране Телефония → Журнал звонков → Аналитика: входящие, принятые и пропущенные, уровень обслуживания, ожидание, брошенные, AHT, FCR, CSAT. Смотрите coverage и history.gaps, чтобы не принять пробел в данных за отсутствие звонков.
Правила звонков#
Пути — относительно /pbx-api/v1; location обязателен, если у ключа несколько точек.
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /routes |
routes:read |
location |
location,lines,trunks,inbound,outbound |
| GET | /routes/history |
routes:read |
location, section=inbound|outbound |
location,section,status,items[]{versionId,at,current,summary},truncated? |
| POST | /routes/inbound |
routes:write |
location, rule, position? |
location,rule,order,rev,warnings? |
| PUT | /routes/inbound/{id} |
routes:write |
location, rule, position? |
то же |
| DELETE | /routes/inbound/{id} |
routes:write |
location |
location,order,rev |
| POST | /routes/inbound/{id}/move |
routes:write |
location, position |
location,rule,order,rev,warnings? |
| POST | /routes/outbound/rules |
routes:write |
location, rule, position? |
location,rule,outbound,rev |
| PUT | /routes/outbound/rules/{id} |
routes:write |
location, rule, position? |
то же |
| DELETE | /routes/outbound/rules/{id} |
routes:write |
location |
location,outbound,rev |
| POST | /routes/outbound/rules/{id}/move |
routes:write |
location, position |
location,rule,outbound,rev |
| PATCH | /routes/outbound |
routes:write |
location, enabled?, fallback? |
location,outbound,rev |
| POST | /routes/history/restore |
routes:write |
location, section, versionId |
location,section,versionId,restored |
position начинается с 1. Правка правила меняет только переданные поля, null удаляет поле. Поля правила можно передать в корне тела или объектом rule. Срабатывает первое подходящее входящее правило; warnings:["after_catch_all"] значит, что выше уже стоит безусловное правило и новое не сработает.
Входящее rule: name, enabled, match, when, action, extension, botId, forwardNumber, menuId, fallback. match.kind: any, time (days, from, to), callerPrefix, line (lineId), trunk (trunkId). Чтобы направить звонок группе, используйте action:"extension" и добавочный группы; сложное распределение по людям настраивается в админке.
Исходящее rule: name, enabled, pattern, via, scope, stripDigits, prefix, backupVia, backupVias. Шаблон: цифры, X (0–9), N (2–9), Z (1–9), начальный +, завершающая точка. via — {kind:"line",lineId} или {kind:"trunk",trunkId}; scope — {kind:"all"} или {kind:"extensions",extensions:["101"]}. Разрешённые страны и защиту от мошенничества ключ не меняет.
curl -sS "$BASE/routes?location=loc1" -H "Authorization: Bearer $KEY"
curl -sS -X PUT "$BASE/routes/inbound/r-example" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","rule":{"name":"Дежурный"}}'
curl -sS "$BASE/routes/history?location=loc1§ion=inbound" -H "Authorization: Bearer $KEY"
# versionId — из истории, id правила — из /routes.
curl -sS "$BASE/routes/history/restore" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","section":"inbound","versionId":"version-example"}'
Правила пустой АТС:
{"location":"loc1","lines":[],"trunks":[],"inbound":[],"outbound":null}
Ответ на правку правила:
{"location":"loc1","rule":{"id":"r-example","order":1,"name":"Дежурный","enabled":true,"match":{"kind":"any"},"action":"extension","extension":"101"},"order":["r-example"],"rev":12}
Откат создаёт новую версию: {"location":"loc1","section":"inbound","versionId":"version-example","restored":true}. При конфликте перечитайте правила и историю, прежде чем повторять изменение.
Экспорт и импорт конфигурации#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /config/export |
config:export |
location, tables? (через запятую) |
location,rev,tables |
| POST | /config/import |
config:import |
location, tables, sync?, apply?, baseRev? |
location,applied,changed,rev,tables,errors,newAccounts?,created?,passwords? |
Таблицы — тот же формат, что выгрузка в админке: добавочные, группы, линии, входящие, исходящие. Пароли не выгружаются. Импорт принимает tables как массивы строк или объекты {columns,rows} из экспорта.
curl -fsS "$BASE/config/export?location=loc1" -H "Authorization: Bearer $KEY" > export.json
# Отредактируйте export.json. Предпросмотр ничего не применяет.
jq '{location:"loc1",tables:.tables}' export.json > import.json
curl -fsS "$BASE/config/import" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
--data-binary @import.json > preview.json
cat preview.json
# Только после проверки tables и errors:
jq --slurpfile p preview.json '. + {apply:true,baseRev:$p[0].rev}' import.json > apply.json
curl -fsS "$BASE/config/import" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' --data-binary @apply.json
Экспорт tables=extensions пустого списка добавочных:
{"location":"loc1","rev":12,"tables":{"extensions":{"columns":["extension","name","type","external_number","via","staff_email","staff_name","groups","recording","voicemail","voicemail_email","enabled"],"rows":[]}}}
Предпросмотр без изменений:
{"location":"loc1","applied":false,"changed":false,"rev":12,"tables":[{"table":"extensions","added":0,"changed":0,"removed":0,"unchanged":0,"errors":0,"rows":[]}],"errors":0,"newAccounts":0}
Применение без ревизии отклоняется:
{"error":"base_rev_required","hint":"Run import_config without apply first and pass its rev as baseRev."}
В предпросмотре applied:false; errors — число ошибок, подробности — в tables[].rows[]. Строки с ошибками при применении пропускаются: читайте весь отчёт, а не только HTTP-код. После успешного применения applied:true, rev — новая ревизия; если менять нечего, applied остаётся false. sync:{"<таблица>":true} удаляет строки, которых нет в файле, — включайте осознанно. Конфликт ревизии — повод для нового предпросмотра, а не для подстановки свежего номера в старое применение. Пароли новых добавочных доступны только в админке (passwords:"admin_only").
События и вебхуки#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /events |
calls:read |
location, since, types, limit |
location,events,more,next? |
| GET | /webhooks |
webhooks:write |
— | webhooks,events,limits,delivery |
| POST | /webhooks |
webhooks:write |
name, url, events, locations, enabled |
ok,webhook,secret,created |
| PATCH | /webhooks/{id} |
webhooks:write |
изменяемые поля | ok,webhook,created |
| DELETE | /webhooks/{id} |
webhooks:write |
— | ok,id |
| POST | /webhooks/{id}/rotate-secret |
webhooks:write |
— | ok,webhook,secret |
| POST | /webhooks/{id}/test |
webhooks:write |
— | ok,id,queued,reason? |
| GET | /webhooks/{id}/deliveries |
webhooks:write |
— | id,deliveries,health? |
curl -sS "$BASE/events?location=loc1&limit=100" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/webhooks/wh_0123456789ab/deliveries" -H "Authorization: Bearer $KEY"
{"location":"loc1","events":[],"more":false}
События идут от старых к новым; следующий запрос передаёт since = next предыдущего ответа, в том числе при more:true. types — список через запятую. Лента хранит до 2000 событий точки за 7 дней. Тела событий, подпись и повторы — в статье Вебхуки событий звонков.
Открыто и закрыто#
| Метод | Путь | Право | Параметры | Поля ответа |
|---|---|---|---|---|
| GET | /open-closed |
routes:read |
location |
location,codeEnabled,remoteSupported,closed,closedUntil?,closedBy?,request? |
| PUT | /open-closed |
daynight:write |
location, closed, until? |
ok, rev и состояние точки |
curl -sS "$BASE/open-closed?location=loc1" -H "Authorization: Bearer $KEY"
curl -sS -X PUT "$BASE/open-closed" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","closed":true,"until":"2026-10-25T09:00:00+04:00"}'
{"location":"loc1","codeEnabled":true,"remoteSupported":true,"closed":false}
Облако передаёт запрос АТС за секунды: пока request.applied равен false, АТС его ещё не применила. Без until точка закрыта до конца местных суток. Время без смещения читается в часовом поясе точки; срок — не дальше 30 дней, потом точка откроется сама. closed:false открывает точку и снимает until. Нужна включённая настройка «Закрыть кодом *28» в маршрутизации, иначе 409 daynight_code_off. Закрытие — то же, что *28 с телефона: звонки идут правилами «вне часов», сервер не выключается.
Готовые сценарии#
Вчерашний журнал в CSV: Python#
Установите requests. Задайте PBX_KEY, PBX_TZ (часовой пояс точки, например Asia/Tbilisi) и PBX_LOCATION; запустите скрипт с перенаправлением > calls.csv. Он читает все страницы, экранирует CSV и обезвреживает формулы табличных редакторов. Выгружается то, что есть в окне журнала, а не полный архив.
import csv, os, sys
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
import requests
base = 'https://api.cenaly.ru/pbx-api/v1'
tz = ZoneInfo(os.environ['PBX_TZ'])
today = datetime.now(tz).date()
params = {'location': os.environ['PBX_LOCATION'], 'limit': 100,
'since': datetime.combine(today - timedelta(days=1), time.min, tz).isoformat(),
'until': datetime.combine(today, time.min, tz).isoformat()}
headers = {'Authorization': 'Bearer ' + os.environ['PBX_KEY']}
out = csv.writer(sys.stdout)
out.writerow(['id', 'startedAt', 'from', 'to', 'status', 'durationSec'])
def cell(value):
s = str(value if value is not None else '')
return "'" + s if s.startswith(('=', '+', '-', '@', '\t', '\r', '\n')) else s
while True:
r = requests.get(base + '/calls', params=params, headers=headers, timeout=20)
r.raise_for_status()
page = r.json()
for c in page['items']:
out.writerow(map(cell, [c['id'], c.get('startedAt'),
c.get('from', {}).get('number', ''), c.get('to', {}).get('extension', ''),
c['status'], c.get('durationSec', 0)]))
if not page.get('nextCursor'):
break
params['cursor'] = page['nextCursor']
Звонок в клик из CRM: Node.js#
Код выполняется на сервере CRM (Node.js 18+, файл .mjs), ключ — в PBX_KEY. Привяжите вызов к явному нажатию авторизованного сотрудника и блокируйте двойное нажатие в CRM.
const base = 'https://api.cenaly.ru/pbx-api/v1';
const headers = {'Authorization': `Bearer ${process.env.PBX_KEY}`,
'Content-Type': 'application/json'};
const r = await fetch(`${base}/dial`, {method: 'POST', headers,
body: JSON.stringify({location: 'loc1', extension: '101', number: '+995555123456'}),
signal: AbortSignal.timeout(20000)});
const result = await r.json();
if (!r.ok) throw new Error(JSON.stringify(result));
// Сохраните commandId в действии CRM; POST автоматически не повторяйте.
console.log({commandId: result.commandId, state: result.state});
const status = await fetch(`${base}/dial/${encodeURIComponent(result.commandId)}?location=loc1`,
{headers, signal: AbortSignal.timeout(20000)});
if (!status.ok) throw new Error(await status.text());
console.log(await status.json());
Ночной режим по расписанию: curl#
Планировщик запускает команду вечером; UNTIL — время следующего открытия в ISO со смещением, не дальше 30 дней (для ночи с 24 на 25 октября — 2026-10-25T09:00:00+04:00). Вычисляйте дату заново при каждом запуске.
BASE='https://api.cenaly.ru/pbx-api/v1'
: "${PBX_KEY:?Задайте ключ}" "${UNTIL:?Задайте время открытия}"
BODY=$(jq -nc --arg until "$UNTIL" '{location:"loc1",closed:true,until:$until}')
curl -fsS -X PUT "$BASE/open-closed" -H "Authorization: Bearer $PBX_KEY" \
-H 'Content-Type: application/json' -d "$BODY" | jq -e 'if .error then error(.error) else . end'
Открыть точку раньше — тот же PUT с {"location":"loc1","closed":false}. Проверяйте состояние через GET. Регулярные часы работы без внешнего планировщика настраиваются расписанием маршрутизации в админке.