C cenaly.com
Start free

Your PBX API and MCP

Keys and permissions, REST API, the MCP server for Claude and Cursor, connecting ChatGPT and claude.ai with OAuth without a key, examples, limits and errors

11 min read Admin panel demo
On this page 10

Your PBX API and MCP

Your Cenaly phone system is open to your own software and AI assistants:

  • CRM, accounting, your website — REST API: call log, recordings and transcripts, click-to-call, extensions, forwarding, call rules;
  • Claude Code, Claude Desktop, Cursor — a remote MCP server with a key;
  • ChatGPT, claude.ai, Perplexity, Cursor and VS Code — the same MCP server without a key: you sign in to your account and allow access (OAuth).

Everything acts on behalf of the account owner and only with the permissions the owner granted. Every change shows up in the PBX action log under the name of the key or app.

Addresses#

What Address
REST API https://api.cenaly.com/pbx-api/v1
MCP server (streamable HTTP) https://api.cenaly.com/pbx-api/mcp
Method reference api.cenaly.com/pbx-api/v1/docs
OpenAPI 3.1 description api.cenaly.com/pbx-api/v1/openapi.json
OAuth metadata https://api.cenaly.com/.well-known/oauth-authorization-server/pbx-api

Keys and permissions#

Only the account owner creates keys: Telephony → API & MCP → Keys → Create key. An employee with the "PBX administrator" role does not see keys — a key acts on behalf of the owner.

  • A cpx_… key is shown once — right after creation, together with ready-to-paste connection snippets. We store only its fingerprint (SHA-256); a lost key cannot be recovered, only replaced.
  • A key can be limited to locations (otherwise all locations, including future ones) and to a term (30, 90, 365 days or no expiry).
  • "Permissions" changes a key's permissions and locations on the fly: the integration keeps working, new permissions apply from the next request.
  • "Revoke" is immediate and final: the next request with that key gets 401.
  • Up to 20 live keys and connections per account.

Pick a ready-made set or tick permissions yourself:

Permission What it gives Sets
calls:read call log and a call card with its route CRM, AI assistant, read-only, all
recordings:read a link to the recording (10 minutes) and the transcript CRM, AI assistant, read-only, all
calls:dial click-to-call: the employee's phone rings first, after they answer the PBX dials the number CRM, AI assistant, all
extensions:read extensions, groups, forwarding, do-not-disturb CRM, AI assistant, read-only, all
extensions:write change personal forwarding and do-not-disturb AI assistant, all
presence:read who is free, ringing, talking AI assistant, read-only, all
routes:read numbers, inbound and outbound rules, rule history read-only, all
routes:write create, change, delete and reorder call rules, roll back from history separate tick only
config:export export PBX settings in the file format separate tick only
config:import load settings from tables: preview, then apply separate tick only
webhooks:write create, change, test and delete call-event webhooks separate tick only
daynight:write close the location until the end of the day or until a date, or open it — same as *28 on a phone separate tick only

Permissions that change something or spend minutes are marked amber. The last three are not part of any set, not even "All" — they can only be granted deliberately, with a separate tick.

ChatGPT, claude.ai, Perplexity, Cursor, VS Code: connect without a key#

Cloud assistants cannot put a key into a header — they connect the MCP server with OAuth. You don't need to create a key.

claude.ai: Settings → Connectors → Add custom connector → paste https://api.cenaly.com/pbx-api/mcp → Connect.

ChatGPT: Settings → Apps & Connectors → Advanced settings → turn on Developer mode → Create → paste https://api.cenaly.com/pbx-api/mcp, authentication OAuth.

Perplexity: Account settings → Connectors → "+ Custom connector" → Remote → paste https://api.cenaly.com/pbx-api/mcp, authentication OAuth.

Cursor and VS Code: add a remote (HTTP) MCP server with the address https://api.cenaly.com/pbx-api/mcp and no key header — the sign-in window opens in the browser. Cursor: .cursor/mcp.json → {"mcpServers": {"cenaly-pbx": {"url": "https://api.cenaly.com/pbx-api/mcp"}}}; VS Code: the "MCP: Add Server" command → HTTP.

The assistant then opens the Cenaly sign-in window. After signing in you see:

  • who is asking — the app is named after the address the answer returns to: "Verified connector · chatgpt.com" (likewise claude.ai, perplexity.ai, vscode.dev, Cursor), "A program on this computer" or an "Unfamiliar address" warning. The name the app sent is shown in small print — anyone can write it;
  • permissions — what the app asked for is ticked (if it asks for "everything", the "AI assistant" set is ticked instead); "separate tick only" permissions are never pre-ticked;
  • locations — all or selected.

"Allow" sends you back to the assistant, which immediately sees the PBX tools. The connection appears in Telephony → API & MCP → Keys as a row with an OAuth badge and the app's address — change its permissions or revoke it there. Connecting the same app again updates its permissions instead of adding a second connection.

Only the account owner can allow connections.

Claude Code, Claude Desktop, Cursor: connect with a key#

Claude Code — one command:

claude mcp add --transport http telephony https://api.cenaly.com/pbx-api/mcp --header "Authorization: Bearer cpx_…"

Claude Desktop, Cursor (.cursor/mcp.json) and other clients with a settings file:

{
  "mcpServers": {
    "telephony": {
      "type": "http",
      "url": "https://api.cenaly.com/pbx-api/mcp",
      "headers": { "Authorization": "Bearer cpx_…" }
    }
  }
}

Snippets with your key are shown right after the key is created. The assistant sees only the tools the key allows and can answer "who called today and didn't get through", "call this guest back from Anna's phone", "forward my calls to my mobile".

REST API#

The key goes into Authorization: Bearer <key> (or X-Api-Key). If the key has one location, location can be omitted; with several, a 400 location_required response lists them.

Method Path Permission What it does
GET /v1/me — the key's permissions, locations and limits
GET /v1/locations — the key's locations and their phone systems
GET /v1/calls calls:read call log: since, until, direction, status (answered, missed, ai), number, extension, limit up to 100, cursor
GET /v1/calls/{id} calls:read a call with its route: which rule matched, whose phone rang, who answered
GET /v1/calls/{id}/recording recordings:read a 10-minute link to the recording
GET /v1/calls/{id}/transcript recordings:read transcript and summary
POST /v1/dial calls:dial click-to-call: { "extension": "101", "number": "+995555123456" }
GET /v1/dial/{commandId} calls:dial click-to-call progress
GET /v1/extensions extensions:read extensions, groups, forwarding, do-not-disturb
PUT /v1/extensions/{ext}/forwarding extensions:write personal forwarding of an extension
PUT /v1/extensions/{ext}/dnd extensions:write do-not-disturb: { "on": true }
GET /v1/presence presence:read who is free right now
GET /v1/routes routes:read numbers, inbound and outbound rules
GET /v1/routes/history routes:read rule change history
POST, PUT, DELETE /v1/routes/inbound…, /v1/routes/outbound… routes:write call rules one at a time: create, change the fields passed, delete, reorder, roll back
GET, POST /v1/config/export, /v1/config/import config:export, config:import settings in the file format; loading — preview, then apply
GET /v1/events calls:read call events of the location for the last 7 days (up to 2000): since (event id or time), types, limit (up to 500 per answer)
GET, POST, PATCH, DELETE /v1/webhooks… webhooks:write webhooks: list, create, change, delete; …/rotate-secret, …/test, …/deliveries
GET, PUT /v1/open-closed routes:read, daynight:write closed or not and until when; close until the end of the day { "closed": true }, until a date { "closed": true, "until": "2026-10-05T09:00" } or open

All fields are described in the method reference and in OpenAPI.

Who called today and didn't get through:

curl -H "Authorization: Bearer cpx_…" \
  "https://api.cenaly.com/pbx-api/v1/calls?status=missed&since=$(date -u +%Y-%m-%dT00:00:00Z)"

Call a customer back from extension 101:

curl -X POST -H "Authorization: Bearer cpx_…" -H "Content-Type: application/json" \
  -d '{"extension":"101","number":"+995555123456"}' "https://api.cenaly.com/pbx-api/v1/dial"

Forward to a mobile if the employee doesn't answer within 15 seconds:

curl -X PUT -H "Authorization: Bearer cpx_…" -H "Content-Type: application/json" \
  -d '{"forward":{"noAnswer":{"kind":"number","number":"+995599000111"},"noAnswerSec":15}}' \
  "https://api.cenaly.com/pbx-api/v1/extensions/101/forwarding"

MCP tools#

The same operations as REST. A client sees only the tools its key or connection allows:

Tools Permission
list_locations —
list_calls, get_call calls:read
get_call_recording, get_call_transcript recordings:read
call_number, get_dial_status calls:dial
list_extensions extensions:read
set_forwarding, set_do_not_disturb extensions:write
get_presence presence:read
list_routes, list_route_history routes:read
save_inbound_rule, delete_inbound_rule, move_inbound_rule, save_outbound_rule, delete_outbound_rule, move_outbound_rule, set_outbound_calls, restore_route_version routes:write
export_config config:export
import_config config:import
get_call_events calls:read
list_webhooks, save_webhook, delete_webhook, rotate_webhook_secret, test_webhook, list_webhook_deliveries webhooks:write
get_open_closed routes:read
set_open_closed daynight:write

The server is stateless (streamable HTTP, JSON-RPC 2.0): initialize, tools/list, tools/call.

OAuth for developers of their own connectors#

The authorization server follows the MCP authorization specification — any client that supports it will work:

  1. A request to MCP without a token gets 401 with WWW-Authenticate: Bearer resource_metadata="https://api.cenaly.com/.well-known/oauth-protected-resource/pbx-api/mcp".
  2. The resource metadata (RFC 9728) names the authorization server https://api.cenaly.com/pbx-api; its metadata (RFC 8414) is at https://api.cenaly.com/.well-known/oauth-authorization-server/pbx-api (and …/pbx-api/.well-known/openid-configuration).
  3. Dynamic client registration (RFC 7591): POST /pbx-api/oauth/register. Redirect URIs: only localhost / 127.0.0.1 (any port) or a known connector host (claude.ai, claude.com, chatgpt.com, chat.openai.com, vscode.dev, www.perplexity.ai, enterprise.perplexity.ai; for Cursor also exactly cursor://anysphere.cursor-mcp/oauth/callback). Client authentication: none (public client), client_secret_post or client_secret_basic.
  4. Authorization code with PKCE (S256 only): GET /pbx-api/oauth/authorize → the owner's sign-in and consent window → back to your address with code, state and iss. The resource parameter is the MCP server address.
  5. Tokens: POST /pbx-api/oauth/token. The code is single-use and lives 2 minutes (a wrong code_verifier burns it); the access token lives 1 hour; the refresh token lives 90 days, each refresh returns a new one and the previous one stops working.
  6. Revocation: POST /pbx-api/oauth/revoke (RFC 7009) ends the whole connection — like "Revoke" in the API & MCP window.

The access token is accepted wherever a key is: in MCP and in REST, with the permissions and locations the owner allowed.

Limits and errors#

  • Per key or connection: 120 requests per minute and 20,000 per day (REST and MCP together).
  • Click-to-call separately: 10 per minute and 300 per day.
  • Call log — up to 100 calls per request; the location's PBX log keeps the last 400 calls for 90 days.
Code Error What to do
401 api_key_missing, api_key_invalid no key, or it was revoked or expired; an OAuth access token expired — refresh it
403 scope_required the key lacks the permission — add it in "Permissions"
400 location_required the key has several locations — pass location
404 location_not_found no such location, or the key is limited to other locations
429 rate_limited limit reached, retry after Retry-After seconds

Call events#

Real time — PBX webhooks: Telephony → API and MCP → Webhooks or POST /v1/webhooks with a key that has webhooks:write. Your https address gets a POST within seconds for every event: call.started (direction, who, where, line), call.ringing (whose phone rings — one per ringing phone), call.answered (who answered), call.transferred (who transferred to whom), call.ended (result, duration, talk time, who answered). A webhook can be limited to locations and event types.

{ "id": "1727771234.56:2", "type": "call.answered", "createdAt": "2026-10-01T10:00:05Z",
  "locationId": "…", "webhookId": "wh_…", "attempt": 1,
  "data": { "callId": "1727771234.56", "seq": 2, "direction": "inbound",
            "from": { "number": "+995555123456" }, "party": { "extension": "101", "name": "Anna" } } }

Signature — header X-Cenaly-Signature: t=<unix time>,v1=<HMAC-SHA256 of "t.body" with the webhook secret>. Recompute, compare in constant time and reject t older than 5 minutes. The whsec_… secret is shown once; "New secret" revokes the old one immediately.

Delivery — answer 2xx within 10 seconds. Otherwise we retry after 10 s, 30 s, 2, 5, 10, 15 and 15 minutes (8 attempts, about an hour); a retry carries the same id — use it to drop duplicates. The delivery log (status code, time, attempt) is in the webhook window and at GET /v1/webhooks/{id}/deliveries; "Test" sends a webhook.ping. If the address accepts no delivery for 3 days in a row, the webhook switches itself off: the owner gets an email, the Webhooks window shows "Switched off automatically" with an "Enable" button (or PATCH /v1/webhooks/{id} with { "enabled": true }).

No server of your own — the same feed by polling: GET /v1/events?since=<last event id> or the get_call_events MCP tool (events of the last 24 hours).

After the recording is processed — the call with transcript and analysis arrives via the "Webhook" source in Calls.

Open / closed — PUT /v1/open-closed with { "closed": true } closes the location until the end of the day (like *28 on a phone: calls follow the after-hours rules), false opens it; GET shows whether it is closed and by whom. With "until" it stays closed until that moment (at most 30 days) and reopens by itself; a time without an offset (2026-10-05T09:00) is read in the location time zone; false cancels it too. Requires "Close with *28" to be on in the routing settings (409 otherwise).

Security#

  • A key is shown once and we keep only its fingerprint; a leaked key is revoked with one click.
  • Only the owner can allow an OAuth connection, and you see which address gets access; an allowed connection can be limited to permissions and locations and revoked at any time.
  • Phone numbers are your customers' personal data: grant call log and recording permissions only to integrations that really need them.
  • Every change made with a key or connection is in the PBX action log under the key's or app's name.
Was this article helpful?