C cenaly.ru
Start free

Your PBX REST API

Every PBX REST API method with permissions, parameters and responses: call log, recordings and transcripts, click-to-call, forwarding and do-not-disturb, metrics, call rules, settings import, errors and ready-made scenarios

15 min read Admin panel demo
On this page 13

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&section=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.

See also#

Was this article helpful?