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.meni.ge/pbx-api/v1 |
| MCP server (streamable HTTP) | https://api.meni.ge/pbx-api/mcp |
| Method reference | api.meni.ge/pbx-api/v1/docs |
| OpenAPI 3.1 description | api.meni.ge/pbx-api/v1/openapi.json |
| OAuth metadata | https://api.meni.ge/.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.meni.ge/pbx-api/mcp → Connect.
ChatGPT: Settings → Apps & Connectors → Advanced settings → turn on Developer mode → Create → paste https://api.meni.ge/pbx-api/mcp, authentication OAuth.
Perplexity: Account settings → Connectors → "+ Custom connector" → Remote → paste https://api.meni.ge/pbx-api/mcp, authentication OAuth.
Cursor and VS Code: add a remote (HTTP) MCP server with the address https://api.meni.ge/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.meni.ge/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.meni.ge/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.meni.ge/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.meni.ge/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.meni.ge/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.meni.ge/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:
- A request to MCP without a token gets 401 with
WWW-Authenticate: Bearer resource_metadata="https://api.meni.ge/.well-known/oauth-protected-resource/pbx-api/mcp". - The resource metadata (RFC 9728) names the authorization server
https://api.meni.ge/pbx-api; its metadata (RFC 8414) is athttps://api.meni.ge/.well-known/oauth-authorization-server/pbx-api(and…/pbx-api/.well-known/openid-configuration). - Dynamic client registration (RFC 7591):
POST /pbx-api/oauth/register. Redirect URIs: onlylocalhost/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 exactlycursor://anysphere.cursor-mcp/oauth/callback). Client authentication:none(public client),client_secret_postorclient_secret_basic. - Authorization code with PKCE (
S256only):GET /pbx-api/oauth/authorize→ the owner's sign-in and consent window → back to your address withcode,stateandiss. Theresourceparameter is the MCP server address. - Tokens:
POST /pbx-api/oauth/token. The code is single-use and lives 2 minutes (a wrongcode_verifierburns 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. - 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.