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

22 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, 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 (window in 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, expiresIn may be shorter than 600 seconds; if storage does not answer — 501;
  • POST /dial — the PBX dials at once and answers state:"ringing", fast:true; ask for the call progress with GET /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 example outbound_unavailable) comes from the direct address at once as a 409 answer, and from the cloud in the reason of 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 no rev; closing with until and 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&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. 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.

See also#