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, direct PBX address, errors, call log, click-to-call, extensions, metrics, rules, configuration, events, open and closed, scenarios.
Address and key#
The cloud API address is https://api.cenaly.ge/pbx-api/v1; it works for every location. A location with its own PBX also has a direct address on the PBX server itself — https://sip-<location id>.cenaly.ge/pbx-api/v1: the same API without a hop through the cloud (details in Direct PBX address). 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.
KEY='cpx_…'
CLOUD='https://api.cenaly.ge/pbx-api/v1'
# The direct address of location loc1 if it is ready, otherwise the cloud one:
BASE=$(curl -fsS "$CLOUD/me" -H "Authorization: Bearer $KEY" \
| jq -r '.direct.loc1 | select(.ready == true) | .rest // empty')
BASE=${BASE:-$CLOUD}
curl -sS "$BASE/me" -H "Authorization: Bearer $KEY"
Below, $BASE in the examples is this address.
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.ge/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 |
The direct PBX address can also return 501 served_by_cloud and 503 with cloudUrl — see Direct PBX address.
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#
For a location with its own PBX, the same API is also served by the PBX server itself: https://sip-<location id>.cenaly.ge/pbx-api/v1 (the location id in the server name is lowercase). The call log, events, presence and click-to-call answer from there without a hop through the cloud — faster (about 0.5 s against 2–3 s in our measurements) and even when the cloud is unavailable. The key is the same; paths, parameters, fields and error codes are the same. The cloud address https://api.cenaly.ge/pbx-api/v1 keeps working for every location — it is the fallback with the same key.
How to find the address. In Telephony → API and MCP → How to connect, pick the location: the window shows "direct" and ready-made snippets with the direct address, or "via the cloud". In code — the direct field of the cloud /me (a map by location id) and /locations:
{"direct":{"loc1":{"ready":true,"rest":"https://sip-loc1.cenaly.ge/pbx-api/v1","mcp":"https://sip-loc1.cenaly.ge/pbx-api/mcp","mcpReady":true},"loc2":{"ready":false,"reason":"no_feature"}}}
ready:true — direct REST works; mcpReady:true — MCP and click-to-call also work on the PBX. With ready:false the reason is in reason:
reason |
Meaning |
|---|---|
no_feature |
the location has no PBX of its own, or its firmware cannot serve the direct API — use the cloud |
no_host |
the PBX server name is not confirmed yet |
cert |
everything is ready except the certificate: the PBX server has not received a public certificate for its name yet — the direct address turns on by itself; until then the cloud address works |
lease |
the PBX can serve the direct API, but the address is not on yet: the PBX has not received the keys or has not confirmed them with the cloud for a long time — usually clears by itself |
OAuth connections do not get the direct field: the direct address accepts only cpx_… keys. The PBX server itself answers /me with "servedBy":"pbx" and lists only its own location in /locations. Every response from it carries the X-Cenaly-Served-By: pbx header, so you can see who answered.
Keys on the PBX. The cloud passes a new key, new permissions and revocation to the PBX by itself — within seconds, up to 2–3 minutes for a freshly connected PBX. Until a change arrives, the direct address answers 401 to the new key — retry later or go to the cloud. A revoked key also stops working on the direct address within seconds.
What the cloud answers. Transcripts, metrics, call rules, configuration export and import, webhooks, openapi.json and docs live in the cloud. On the direct address these requests get 501 with a ready cloud address — repeat the same request at cloudUrl with the same key (the permission is checked first, so without it you get 403, not 501):
{"error":"served_by_cloud","cloudUrl":"https://api.cenaly.ge/pbx-api/v1/routes?location=loc1"}
How operations differ on the PBX:
/calls— the PBX log window is 30 days and 20,000 calls (windowin the response), the cloud's is 90 days and 400 calls;/events— the feed lives in the PBX server's memory: after it restarts, earlier events are only in the cloud;/calls/{id}/recording— the PBX signs the link itself,expiresInmay be shorter than 600 seconds; if storage does not answer — 501;POST /dial— the PBX dials at once and answersstate:"ringing",fast:true; ask for the call progress withGET /dial/{commandId}where you started it (the PBX does not know cloud commands — 501). If a call cannot be started, the direct and cloud addresses answer with the same code in the same order (see click-to-call refusals); the only difference: a reason the PBX finds out itself (for exampleoutbound_unavailable) comes from the direct address at once as a 409 answer, and from the cloud in thereasonof the call progress;- the PBX changes DND and forwarding itself only for employees whose state it manages; in other cases, and for
until:"morning", conditional forwarding and a number not in E.164 — 501; PUT /open-closed— the response has norev; closing withuntiland opening a location closed until a date — 501.
Errors that occur only on the direct address:
| HTTP | error |
What to do |
|---|---|---|
| 501 | served_by_cloud |
repeat the request at cloudUrl |
| 503 | direct_api_unavailable |
the PBX has not received the keys from the cloud — request cloudUrl |
| 503 | lease_expired |
the PBX has not confirmed the keys with the cloud for over a day (or does not trust its clock) and closed direct access — request cloudUrl |
| 503 | journal_unavailable |
the log on the PBX is temporarily unavailable — cloudUrl |
| 400 | query_token_forbidden, body_not_allowed |
key in the request URL; body on a GET |
| 403 | origin_forbidden |
a browser request from another site — the direct address is closed to browser JavaScript |
| 413 | body_too_large |
request body larger than 64 KiB |
| 421 | misdirected_request |
the request reached the wrong server — check the name from direct.rest |
Rule for an integration: keep the direct address primary for a location with its own PBX and the cloud address as the fallback. 501 and 503 responses with cloudUrl mean the PBX did not execute the request — they are safe to repeat in the cloud. A network error or read timeout can also be repeated in the cloud, except for POST /dial: the call may already have started.
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.
A refusal before the call is an answer without commandId; the employee's phone did not ring. The checks run in this order, and the direct PBX address answers with the same codes as the cloud one:
| Code | Error | Meaning |
|---|---|---|
| 422 | number_invalid, number_short |
the number could not be parsed or is too short |
| 422 | number_denied |
a satellite or premium-rate destination — the PBX never dials those |
| 404 | no_extension |
the location has no such extension |
| 409 | extension_disabled, extension_external |
the extension is disabled; the employee has no phone on the PBX (an external number only) |
| 409 | extension_unknown |
the extension is not on the PBX server yet (changes have not arrived) |
| 409 | extension_dnd |
the employee has Do Not Disturb on |
| 409 | extension_offline |
the employee's phone is offline |
| 409 | extension_busy |
the employee is already talking or being called |
After that the PBX server decides: for example, outbound_unavailable — the location has no outbound line. Through the cloud such a reason comes in the reason of the call progress (stage:"failed"); on the direct address — at once, as a 409 answer with the same code.
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.
What service level, wait, missed and abandoned mean, and why missed and abandoned must not be added up, is explained in Call-centre board. The sl field of the response holds the same settings as the Service level tab in board settings: answer time, target and short-abandon threshold.
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. On the direct address the PBX closes and opens the location itself, at once, and sends closing with until to the cloud (501 with cloudUrl). 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 takes the location's direct address if it is ready, and on 501/503 with cloudUrl or a network error reads through the cloud. 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
cloud = 'https://api.cenaly.ge/pbx-api/v1'
loc = os.environ['PBX_LOCATION']
tz = ZoneInfo(os.environ['PBX_TZ'])
today = datetime.now(tz).date()
params = {'location': loc, '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']}
me = requests.get(cloud + '/me', headers=headers, timeout=20)
me.raise_for_status()
direct = (me.json().get('direct') or {}).get(loc) or {}
base = direct['rest'] if direct.get('ready') else cloud
def get(path):
global base
# A direct-address cursor is not valid in the cloud: switch only before the first page.
switch = base != cloud and 'cursor' not in params
try:
r = requests.get(base + path, params=params, headers=headers, timeout=20)
if r.status_code not in (501, 503) or not switch:
return r
except requests.RequestException:
if not switch:
raise
base = cloud # the PBX did not answer or sent us to the cloud: use the cloud from now on
return requests.get(base + path, params=params, headers=headers, timeout=20)
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 = get('/calls')
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. The call goes through the PBX server if the location has a ready direct address with mcpReady; a 501/503 response means the PBX did not start the call, and then the same request goes to the cloud. The call progress is asked where the call was started.
const cloud = 'https://api.cenaly.ge/pbx-api/v1';
const loc = 'loc1';
const headers = {'Authorization': `Bearer ${process.env.PBX_KEY}`,
'Content-Type': 'application/json'};
const me = await (await fetch(`${cloud}/me`, {headers, signal: AbortSignal.timeout(20000)})).json();
const direct = me.direct?.[loc];
const body = JSON.stringify({location: loc, extension: '101', number: '+995555123456'});
const dial = (base) => fetch(`${base}/dial`, {method: 'POST', headers, body,
signal: AbortSignal.timeout(20000)});
let base = direct?.ready && direct.mcpReady ? direct.rest : cloud;
let r = await dial(base);
if (base !== cloud && (r.status === 501 || r.status === 503)) r = await dial(base = cloud);
const result = await r.json();
if (!r.ok) throw new Error(JSON.stringify(result));
// Store commandId and base in the CRM action; do not retry the POST automatically after a failure.
console.log({commandId: result.commandId, state: result.state, base});
const status = await fetch(`${base}/dial/${encodeURIComponent(result.commandId)}?location=${loc}`,
{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. Closing until a date is done by the cloud, so the cloud address is used here even for a location with its own PBX.
BASE='https://api.cenaly.ge/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.