Your PBX REST API
For developers of a CRM, accounting system or your own dashboard: the REST API reads calls and recordings, starts a call from an employee's phone and manages PBX settings. The owner creates a key with the permissions needed — the integration acts on the owner's behalf and only within those permissions. Keys, permissions, OAuth and limits are covered in the overview Your PBX API and MCP.
In this article: address and key, errors, call log, click-to-call, extensions, metrics, rules, configuration, events, open and closed, scenarios.
Address and key#
The API address is https://api.cenaly.ru/pbx-api/v1. The key goes in the Authorization: Bearer cpx_… or X-Api-Key: cpx_… header. Do not put the key in the request URL or in browser JavaScript.
BASE='https://api.cenaly.ru/pbx-api/v1'
KEY='cpx_…'
curl -sS "$BASE/me" -H "Authorization: Bearer $KEY"
The owner creates the key: Telephony → API and MCP → Keys → Create key. The secret is shown once; keep it in a secrets manager. The examples use the placeholder cpx_… instead of the secret.
| Preset | Permissions |
|---|---|
| CRM | calls:read, recordings:read, calls:dial, extensions:read |
| AI assistant | CRM + extensions:write, presence:read |
| Read only | calls:read, recordings:read, extensions:read, routes:read, presence:read, reports:read |
| Everything | all regular permissions except the five that need separate consent |
routes:write, config:export, config:import, webhooks:write, daynight:write are not part of any preset — tick them separately if you need them.
location is the location id from /locations. For GET it goes in the query string, for POST/PUT/PATCH — in the JSON body or the query string. If the key has exactly one location, the parameter can be omitted; otherwise a 400 location_required response lists the available locations. An integration should always pass location: the key may get a second location later.
Specification: OpenAPI 3.1; reference with examples — api.cenaly.ru/pbx-api/v1/docs. The response examples in this article are built from the code with made-up data; optional fields depend on the data and the PBX settings.
Limits and errors#
120 requests per minute and 20,000 per day per key or connection — a budget shared by REST and MCP. Click-to-call has an extra limit: 10 per minute and 300 per day. The call log returns up to 100 calls per request, the event feed — up to 500.
An error is JSON with an error field, for example {"error":"scope_required","scope":"calls:read"}. Do not rely on a message field.
| HTTP | Example error |
What to do |
|---|---|---|
| 400 | invalid_json, location_required, time_invalid, cursor_expired |
fix the JSON or parameters; with an expired cursor start the listing again |
| 401 | api_key_missing, api_key_invalid |
check the key, its term and revocation; refresh an OAuth token |
| 403 | scope_required, locations_outside_key |
grant the permission; narrow the webhook's locations to the key's locations |
| 404 | not_found, location_not_found, call_not_found, recording_not_found |
check the path, the id, the key's access to the location and the retention window |
| 409 | rev_conflict, transcript_in_progress, daynight_code_off |
re-read the state; wait for the transcript; turn on closing with the *28 code |
| 429 | rate_limited |
wait Retry-After seconds (60) |
| 429 | dial_rate_limited |
the response body has retryAfterSec: 60; this error has no Retry-After header |
| 503 | auth_unavailable |
temporary key-check failure — retry later |
Do not retry a call start automatically after a network failure with an unknown outcome: the call may already have started. /dial has no separate idempotency key.
Key and locations#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| GET | /me |
any key | — | key{id,name,hint,scopes,locations,expiresAt?}, limits, direct? |
| GET | /locations |
any key | — | items[]{id,name,pbx,direct?}; pbx may be null |
curl -sS "$BASE/me" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/locations" -H "Authorization: Bearer $KEY"
/me for a key covering all locations (locations: [] — all locations, including future ones):
{"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 for a location without a configured PBX:
{"items":[{"id":"loc1","name":"Main location","pbx":null}]}
Direct PBX address — coming#
For a location with its own PBX, requests will be able to go straight to the PBX server, bypassing the cloud. The direct field in /me (a map by location id) and in /locations items already shows whether such an address is ready: ready, reason, and — once the server name is known — rest and mcp. While this is being rolled out, ready is false: use https://api.cenaly.ru/pbx-api/v1. It stays available after the direct address appears — as the fallback path. OAuth connections do not get the direct field.
Call log#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| 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 in the path |
location, call with 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 is inclusive, until is exclusive: ISO time with an offset is convenient for a local day. Directions are inbound, outbound, internal, transfer. Status is an exact value or a group: answered includes forwarded; missed — missed, busy, cancelled, abandoned; ai — bot, secretary, voicemail. number matches part of the number's digits, extension — the extension of a party or of whoever answered, not of every phone that rang.
Default limit=50, maximum 100; newest calls first. Pass the nextCursor you received unchanged and with the same filters until the field disappears.
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}}
The API sees the location's last 400 calls over 90 days (window); full:true means the window is full and older calls are no longer visible. If you need a full archive, keep the log on your side.
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":[]}}
The recording response:
{"callId":"1792742400.2","url":"https://example.com/temporary-audio","expiresIn":600,"format":"wav"}
The URL here is a placeholder; use exactly the link you are given. It is temporary — 10 minutes, the exact term is in expiresIn. Transcript:
{"callId":"1792742400.2","recId":"rec-example","language":"en","text":"Hello.","segments":[{"speaker":"1","start":0,"end":1.2,"text":"Hello."}]}
404 transcript_not_ready and 409 transcript_in_progress are different stages: there is no transcript yet, or it is being made right now. summary is optional — do not require it to show the text.
Click-to-call#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| POST | /dial |
calls:dial |
location, extension, number |
commandId,location,extension,number,e164?,state,fast |
| GET | /dial/{commandId} |
calls:dial |
location |
commandId,state,stage,reason? |
The employee's phone rings first; after they answer, the PBX dials the external number. Outbound permissions and the location's restrictions still apply.
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" means the command was accepted, not that a conversation took place. fast:false warns that the first call may wait up to a minute for the PBX to come online; for the next few minutes commands go out in seconds. Track the call by commandId:
stage |
What is happening |
|---|---|
queued |
the command is waiting for the PBX |
sent |
the PBX picked up the command |
calling_you |
the employee's phone is ringing |
dialing_client |
the employee answered, the PBX is dialing the customer |
connected |
the customer answered |
ended |
the call is over |
failed |
it did not work; the reason is in reason |
{"commandId":"cb-0123456789abcdef0123","state":"ringing","stage":"dialing_client"}
Check the outcome of the conversation against the call log or the events.
Extensions, forwarding, DND and presence#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| 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 is person, group or room. presence in the extension list appears only with the presence:read permission. live:false warns that the data is not the PBX's live state (for example, it has not sent a snapshot for a while).
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":"Anna","enabled":true,"forwarding":null}]}
{"location":"loc1","extension":"102","forwarding":{"always":{"kind":"extension","extension":"101"}},"supported":true}
An empty presence list:
{"location":"loc1","live":true,"items":[]}
forward accepts the conditions always, busy, noAnswer, offline, dnd, offShift and the number noAnswerSec. The target is {kind:"extension",extension}, {kind:"number",number} or {kind:"voicemail"}. forward:null removes forwarding. Send the whole desired object: this is not a partial update.
The response to turning DND off while the PBX is still applying the request:
{"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 turns it off; until when turning it on is Unix seconds or "morning" (start of the next shift, otherwise 08:00 local time), and the PBX lifts the mode by itself. dnd shows both the request and whether it was applied: do not tell the user "done" while pending:true.
Metrics#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| GET | /metrics |
reports:read |
location, from, to, compare, sections, callbacks |
location,tz,today,history,range,coverage,sl, selected sections, compare, compareSkipped?, callbacks? |
from/to are the location's local dates YYYY-MM-DD, inclusive; without them — the last 7 days. compare: prev, year, prev,year or empty. Sections: metrics,loss,queues,lines,operators,directions,heatmap,daily; heatmap and daily come only when requested explicitly. callbacks=true adds callback speed (for periods up to 92 days). Comparison with the previous year works for periods up to 400 days.
curl -sS "$BASE/metrics?location=loc1&from=2026-09-01&to=2026-09-30&compare=prev,year" -H "Authorization: Bearer $KEY"
A period with no data:
{"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":{}}
The figures are in the metrics field at the root of the response — the same numbers as on the Telephony → Call log → Analytics screen: inbound, answered and missed, service level, waiting, abandoned, AHT, FCR, CSAT. Check coverage and history.gaps so as not to mistake a gap in data for an absence of calls.
Call rules#
Paths are relative to /pbx-api/v1; location is required if the key has several locations.
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| 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? |
the same |
| 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? |
the same |
| 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 starts at 1. Editing a rule changes only the fields you pass, null removes a field. Rule fields can be passed at the root of the body or as a rule object. The first matching inbound rule wins; warnings:["after_catch_all"] means an unconditional rule already sits above, so the new one will never match.
Inbound rule: name, enabled, match, when, action, extension, botId, forwardNumber, menuId, fallback. match.kind: any, time (days, from, to), callerPrefix, line (lineId), trunk (trunkId). To send a call to a group, use action:"extension" with the group's extension; complex distribution among people is set up in the admin panel.
Outbound rule: name, enabled, pattern, via, scope, stripDigits, prefix, backupVia, backupVias. Pattern: digits, X (0–9), N (2–9), Z (1–9), a leading +, a trailing dot. via is {kind:"line",lineId} or {kind:"trunk",trunkId}; scope is {kind:"all"} or {kind:"extensions",extensions:["101"]}. A key cannot change the allowed countries or the fraud protection.
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":"On duty"}}'
curl -sS "$BASE/routes/history?location=loc1§ion=inbound" -H "Authorization: Bearer $KEY"
# versionId comes from the history, the rule id — from /routes.
curl -sS "$BASE/routes/history/restore" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"location":"loc1","section":"inbound","versionId":"version-example"}'
Rules of an empty PBX:
{"location":"loc1","lines":[],"trunks":[],"inbound":[],"outbound":null}
The response to a rule edit:
{"location":"loc1","rule":{"id":"r-example","order":1,"name":"On duty","enabled":true,"match":{"kind":"any"},"action":"extension","extension":"101"},"order":["r-example"],"rev":12}
A restore creates a new version: {"location":"loc1","section":"inbound","versionId":"version-example","restored":true}. On a conflict, re-read the rules and the history before repeating the change.
Configuration export and import#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| GET | /config/export |
config:export |
location, tables? (comma-separated) |
location,rev,tables |
| POST | /config/import |
config:import |
location, tables, sync?, apply?, baseRev? |
location,applied,changed,rev,tables,errors,newAccounts?,created?,passwords? |
Tables use the same format as the export in the admin panel: extensions, groups, lines, inbound, outbound. Passwords are not exported. Import accepts tables as arrays of rows or {columns,rows} objects from an export.
curl -fsS "$BASE/config/export?location=loc1" -H "Authorization: Bearer $KEY" > export.json
# Edit export.json. The preview applies nothing.
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
# Only after checking tables and 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
Export of tables=extensions with an empty extension list:
{"location":"loc1","rev":12,"tables":{"extensions":{"columns":["extension","name","type","external_number","via","staff_email","staff_name","groups","recording","voicemail","voicemail_email","enabled"],"rows":[]}}}
A preview with no changes:
{"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}
Applying without a revision is rejected:
{"error":"base_rev_required","hint":"Run import_config without apply first and pass its rev as baseRev."}
In the preview applied:false; errors is the number of errors, the details are in tables[].rows[]. Rows with errors are skipped when applying: read the whole report, not just the HTTP code. After a successful apply applied:true and rev is the new revision; if there is nothing to change, applied stays false. sync:{"<table>":true} deletes rows that are not in the file — turn it on deliberately. A revision conflict calls for a new preview, not for plugging a fresh number into the old apply. Passwords of new extensions are available only in the admin panel (passwords:"admin_only").
Events and webhooks#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| 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 |
the fields to change | 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}
Events come oldest first; the next request passes since = next of the previous response, including when more:true. types is a comma-separated list. The feed keeps up to 2,000 events per location over 7 days. Event bodies, the signature and retries are in Call event webhooks.
Open and closed#
| Method | Path | Permission | Parameters | Response fields |
|---|---|---|---|---|
| GET | /open-closed |
routes:read |
location |
location,codeEnabled,remoteSupported,closed,closedUntil?,closedBy?,request? |
| PUT | /open-closed |
daynight:write |
location, closed, until? |
ok, rev and the location state |
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}
The cloud passes the request to the PBX within seconds: while request.applied is false, the PBX has not applied it yet. Without until the location is closed until the end of the local day. Time without an offset is read in the location's time zone; the term is at most 30 days, after which the location opens by itself. closed:false opens the location and clears until. The "Close with code *28" setting must be on in routing, otherwise 409 daynight_code_off. Closing is the same as *28 from a phone: calls follow the "after hours" rules, the server is not switched off.
Ready-made scenarios#
Yesterday's call log to CSV: Python#
Install requests. Set PBX_KEY, PBX_TZ (the location's time zone, e.g. Asia/Tbilisi) and PBX_LOCATION; run the script with > calls.csv. It reads every page, escapes CSV and defuses spreadsheet formulas. It exports what is in the call log window, not a full archive.
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']
Click-to-call from a CRM: Node.js#
The code runs on the CRM server (Node.js 18+, an .mjs file), the key is in PBX_KEY. Tie the call to an explicit click by an authorized employee and block double clicks in the 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));
// Store commandId in the CRM action; do not retry the POST automatically.
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());
Night mode on a schedule: curl#
A scheduler runs the command in the evening; UNTIL is the next opening time in ISO with an offset, at most 30 days ahead (for the night of 24 to 25 October — 2026-10-25T09:00:00+04:00). Compute the date afresh on every run.
BASE='https://api.cenaly.ru/pbx-api/v1'
: "${PBX_KEY:?Set the key}" "${UNTIL:?Set the opening time}"
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'
To open the location earlier, send the same PUT with {"location":"loc1","closed":false}. Check the state with GET. Regular opening hours without an external scheduler are set up with a routing schedule in the admin panel.