Phone API
Put the ArcanFlows workspace webphone inside your CRM, ERP or help desk, and read the workspace's call data — in about ten minutes.
What you get
The phone API has three credentials, one per side of the integration. Everything else on this page is built from them.
| Credential | Looks like | Lifetime | Where it lives | What it can do |
|---|---|---|---|---|
| Embed key | pbx_… | until you disable it (optional expiry date) | your backend only — never a browser | mints and revokes browser session tokens (POST / DELETE /api/v1/public/phone/session) |
| Session token | JWT, token_type: "phone_embed" | 15 minutes; renew from renew_before (3 min before expiry) | the browser tab of one seat — inside the ArcanPhone widget or your own UI | the browser-side phone API under /api/v1/public/phone/…: ring, answer, dial, hold, transfer, conference, presence, own call history, realtime socket |
| Server key | pbxs_… | until you disable it | your backend | tenant-wide data under /api/v1/public/phone/server/…, least-privilege by scope: calls, stats, presence, write-back, click-to-call, webhook subscriptions, just-in-time seats |
A typical CRM install holds two keys on the server — a pbx_ key to mint the sessions the widget runs on, and a pbxs_ key for data, seats and click-to-call — and puts only the 15-minute session token in the page.
All paths below are absolute under your API host (https://<your-instance-api>). Keys go in Authorization: Bearer … (or X-API-Key: …) — header only, never a query string. Session tokens and console login tokens are different things: each is refused on the other's routes.
Quickstart (about 10 minutes)
1. Create a pbx_ embed key in the console
Open Phone System → Embed in other software → Embed keys and create a key. The full pbx_… value is shown once — store it in your backend's secrets. While creating it you can set:
- Allowed origins — the host names of the pages that will show the phone (e.g.
crm.example.com). If set, a session minted for any other origin is403, and the same list becomes the widget page'sframe-ancestorspolicy, so no other host can even frame it. - Extension pool — optionally restrict the key to some extensions; a mint for a seat outside the pool is
404.
Each user who will take calls needs a human extension in Web phone or Both mode with an External user id set to your id for that person (Phone System → the extension → External user id). If you would rather not create seats by hand, step 6 shows the just-in-time POST …/server/seats call that finds-or-creates them.
Administrators can do the same over the management API:
POST /api/v1/phone/embed-keyswith a console JWT and thephone_embed.managepermission (the Phone Embed Keys tag in the reference).
2. Your backend mints a session
Exchange the pbx_ key for a session token for one seat, identified by its external_user_id (or extension_id). Do this server-side: a mint that arrives from a browser with an Origin header outside the allowlist is refused.
bashcurl -X POST https://<your-instance-api>/api/v1/public/phone/session \ -H "Authorization: Bearer pbx_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "external_user_id": "4711", "origin": "crm.example.com" }'
json{ "session_token": "<jwt, 15 min>", "token_type": "phone_embed", "jti": "<session id>", "expires_at": "2026-09-11T10:15:00Z", "renew_before": "2026-09-11T10:12:00Z", "api_base": "https://<your-instance-api>/api/v1/public/phone", "ws_url": "wss://…/api/v1/realtime/ws", "tenant_id": "<uuid>", "extension": { "id": "<uuid>", "extension": "201", "display_name": "Jane", "external_user_id": "4711" }, "allowed_origins": ["crm.example.com"] }
The same code in Node (18+, native fetch), as an endpoint your logged-in users can call:
javascript// server.js — runs on YOUR server; the pbx_ key never leaves it const ARCAN_API = process.env.ARCANFLOWS_API; // e.g. https://<your-instance-api> const PBX_KEY = process.env.ARCANFLOWS_PBX_KEY; // pbx_… async function mintPhoneSession(externalUserId, currentToken) { // Renewal is the same route with { session_token }: you get a fresh token // (new jti) and the old one is closed. A first mint names the seat instead. const body = currentToken ? { session_token: currentToken } : { external_user_id: externalUserId, origin: "crm.example.com" }; const res = await fetch(ARCAN_API + "/api/v1/public/phone/session", { method: "POST", headers: { "Authorization": "Bearer " + PBX_KEY, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error("phone session: HTTP " + res.status + " " + (await res.text())); return res.json(); // { session_token, jti, expires_at, renew_before, extension, ... } } // GET /my-backend/phone-session → { session_token } for the signed-in CRM user app.get("/my-backend/phone-session", async (req, res) => { const session = await mintPhoneSession(req.user.crmId, req.query.current); res.json({ session_token: session.session_token, renew_before: session.renew_before }); }); // On your own logout: DELETE /api/v1/public/phone/session/{jti} with the same key
Rules that bite if skipped: the seat must be an active human extension with that external_user_id (else 404); mints are rate-limited per IP — cache the token for its lifetime and renew, do not mint per page view.
3. Mount the widget
The widget is the console's own web phone (dialpad, ring card, hold, consult/transfer, conference, status chip) rendered in an iframe served from ArcanFlows, so microphone permission, WebRTC and reconnects never touch your page's CSP.
html<script src="https://app.arcanflows.com/embed/phone.js"></script> <div id="phone" style="width:360px;height:560px"></div> <script> // Your endpoint from step 2. Called by the SDK before renew_before and on // session_expiring; return the new session_token. async function renewPhoneToken() { const r = await fetch("/my-backend/phone-session"); return (await r.json()).session_token; } const phone = ArcanPhone.mount({ sessionToken: SESSION_TOKEN, // rendered into the page by your backend (never the pbx_ key) container: "#phone", // selector or element locale: "en", // optional onRenewToken: renewPhoneToken, onEvent: function (type, data) { if (type === "ring") openCustomerRecord(data.caller, data.context); // screen-pop before pickup if (type === "call_ended") logCall(data.call_id, data.reason); }, }); // Also available: phone.on(type, cb) → unsubscribe fn, phone.open(), // phone.setStatus("busy" | "available" | "do_not_disturb" | "away", note), // phone.updateToken(token), phone.unmount(), phone.iframe // Team chat (key switch on): phone.openChat({ conversation_id | user_id | external_user_id }) </script>
Self-hosted? Load phone.js from your own console host instead of app.arcanflows.com; the loader derives the iframe URL from the script's origin.
Events delivered to onEvent and phone.on:
| Event | Payload | When |
|---|---|---|
ready | seat details | the phone is connected and can take calls |
ring | call_id, caller, room_name, display_name, ring_window_s, context (the caller-lookup answer, step 7) | an incoming call starts ringing — enough for a screen-pop |
call_started | call_id, direction | a call connected |
call_ended | call_id, reason | a call ended |
status_changed | the seat's new status | the seat's status changed |
presence_changed | directory presence | a colleague's status changed |
session_expiring | — | the SDK is about to call onRenewToken |
session_expired | — | the token expired or was revoked; a renewal is attempted if onRenewToken exists |
error | message, detail | e.g. token renewal failed |
chat_received | conversation_id, message_id, sender_id, sender_name, preview, mention, thread | a colleague wrote in team chat (key's Team chat switch on) |
chat_unread | total, mentions | the seat's unread team-chat counts changed — badge your own header |
The page must be on a host in the key's allowed origins; the seat's extension must be in Web phone or Both mode to answer (the widget says so if it is not); the browser asks for microphone permission on the first answer. A ready-made host page for trying all of this is at /embed/phone-test.html on your console host.
4. Click-to-call — from your page and from your server
From the page you already have the widget, so a button is one line:
javascriptdocument.querySelector("#call-customer").onclick = () => phone.dial("+15551234567"); // phone.dial also accepts an extension number, e.g. phone.dial("121")
From your server — a "Call" button rendered by your backend, an automation, a dialer — use a pbxs_ server key. Create one under Phone System → Embed in other software → Server keys, ticking the scopes you need (the read scopes are on by default; anything that writes or spends is an opt-in). The rest of this quickstart uses that key, so tick calls:originate, calls:read, stats:read, calls:write, recordings:read, webhooks:manage and seats:provision if you want to follow every step.
A server cannot join a WebRTC room, so originate asks the seat's open phone (the widget or a console tab) to dial; it answers 202 and the far end rings exactly like a dialpad click:
bashcurl -X POST https://<your-instance-api>/api/v1/public/phone/server/calls/originate \ -H "Authorization: Bearer pbxs_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "external_user_id": "4711", "to": "+15551234567" }'
json{ "status": "dialing_requested", "extension": "201", "to": "+15551234567" }
Address the seat by external_user_id or extension_id. If that seat has no phone open you get 409 with "message": "seat_offline" — show the user "open your phone first" rather than retrying. Not idempotent: each call rings again. Scope: calls:originate.
5. Subscribe to phone.call.completed and verify the signature
Every call is also a platform event. With the webhooks:manage scope a server key can subscribe a URL to any phone.* type (one subscription per type):
bashcurl -X POST https://<your-instance-api>/api/v1/public/phone/server/subscriptions \ -H "Authorization: Bearer pbxs_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "CRM call log", "event_type": "phone.call.completed", "target_type": "webhook", "webhook_url": "https://crm.example.com/hooks/arcanflows", "webhook_secret": "'"$(openssl rand -hex 32)"'", "retry_policy": { "max_retries": 3, "backoff_seconds": [5, 30, 120] } }'
Deliveries are POSTs with X-Event-Type, X-Event-ID (use it for idempotency) and X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw body with your secret>. Verify over the raw bytes, in constant time, and answer 2xx fast:
javascriptimport crypto from "node:crypto"; import express from "express"; const SECRET = process.env.ARCANFLOWS_WEBHOOK_SECRET; const app = express(); app.post("/hooks/arcanflows", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("X-Webhook-Signature") || ""; const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const a = Buffer.from(header), b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end(); const event = JSON.parse(req.body.toString("utf8")); res.status(202).end(); // acknowledge first, work later // event.event_type === "phone.call.completed" // event.data: call_id, direction, caller, dialed, extension.external_user_id, // duration_s, disposition, summary, has_recording, recording_url (signed, 1 h) … handle(event); });
A call yields either phone.call.completed or phone.call.missed, exactly once; ringing and answered fire once each before it. The full contract — every header, the retry schedule (4 attempts by default, 30 s per attempt), the shared call payload field by field, caller_context, the signed recording_url and how to test against the built-in incoming-webhook receiver — is the Phone Webhooks guide (see Reference).
6. Read call history and statistics with a pbxs_ key
bash# Calls of one of your users in the last 7 days (scope calls:read) curl "https://<your-instance-api>/api/v1/public/phone/server/calls?external_user_id=4711&range=7d&limit=50" \ -H "Authorization: Bearer pbxs_your_key_here" # → { "calls": [ { "id", "direction", "caller", "dialed", "status", "disposition", # "started_at", "answered_at", "ended_at", "duration_s", "extension", # "external_user_id", "external_ref", "outcome", "tags", "notes", # "summary", "category", "sentiment", "has_recording", … } ], # "total": 137, "limit": 50, "offset": 0 } # One call with its transcript turns curl "https://<your-instance-api>/api/v1/public/phone/server/calls/CALL_ID" \ -H "Authorization: Bearer pbxs_your_key_here" # The console's Statistics numbers for the window (scope stats:read): # totals, answer rate, per-extension, per-number, daily buckets, categories, # sentiments and billable minutes by scenario curl "https://<your-instance-api>/api/v1/public/phone/server/stats?range=30d" \ -H "Authorization: Bearer pbxs_your_key_here" # Write YOUR record id and vocabulary onto a call (scope calls:write) — partial, # omitted fields are untouched; filter later with ?external_ref= curl -X PATCH "https://<your-instance-api>/api/v1/public/phone/server/calls/CALL_ID" \ -H "Authorization: Bearer pbxs_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "CRM-4711", "outcome": "qualified", "tags": ["inbound-lead"] }' # A one-hour signed link to the audio (scope recordings:read) — store the file, never the URL curl "https://<your-instance-api>/api/v1/public/phone/server/calls/CALL_ID/recording" \ -H "Authorization: Bearer pbxs_your_key_here" # → { "url": "…/api/v1/public/phone/recordings/<token>", "expires_at": "…", "content_type": "audio/ogg" }
Filters on GET …/server/calls: extension, external_user_id, external_ref, direction (inbound | outbound | internal), status, range (24h | 7d | 30d | 90d | 12m) or since / until (RFC 3339), limit / offset. GET …/server/calls/export.csv takes the same filters. GET …/server/stats/groups?range= adds per-ring-group offered / answered / missed and callers waiting now.
Seats your CRM knows and ArcanFlows does not — with seats:provision, one idempotent call finds or creates the user, a web-mode extension and the external_user_id mapping (201 created, 200 existed). Call it before every mint and you never have to know whether the seat exists:
bashcurl -X POST https://<your-instance-api>/api/v1/public/phone/server/seats \ -H "Authorization: Bearer pbxs_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "external_user_id": "4711", "email": "[email protected]", "first_name": "Jane", "last_name": "Doe" }'
7. Optional: caller lookup — your CRM names the caller
Turn it on under Phone System → Caller lookup (CRM) with a URL, a secret and a timeout (default 800 ms). Before an incoming call is routed, ArcanFlows POSTs to your URL, signed with the same X-Webhook-Signature: sha256=… scheme as step 5:
json{ "event": "caller.lookup", "tenant_id": "…", "caller": "+5215512345678", "dialed": "+19364104110", "at": "2026-09-11T10:00:00Z" }
Answer within the timeout with JSON — every field optional; 404 or 204 means "unknown caller":
json{ "display_name": "Jane Doe", "external_ref": "CRM-4711", "account": "ACME", "tier": "gold", "language": "es", "notes": "Open ticket #88 about billing", "route_hint": "121" }
What happens with the answer: the attendant and voice agents greet the caller by name; the widget's ring event carries it as context so you can open the record before anyone picks up; every phone.call.* event and the call row carry it as caller_context; and with Let the CRM route the call on, a route_hint naming one of your extensions or ring groups becomes the destination. The hook is fail-open: a slow or broken CRM never delays a call beyond the timeout and never changes routing. Use Ask the CRM on the console card to see exactly what your endpoint returns, with latency, before enabling it.
Rate limits
Limits are per minute, fixed window. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After (seconds). Routes not listed have no phone-specific limit.
Key-authenticated public routes — per IP
| Route | Limit / min |
|---|---|
POST /api/v1/public/phone/session (mints and renewals) | 60 |
DELETE /api/v1/public/phone/session/{jti} | 60 |
GET /api/v1/public/phone/openapi.json | 60 |
GET /api/v1/public/embed/frame-policy | 240 |
Browser-side routes (session token) — per seat
| Route | Limit / min |
|---|---|
POST …/phone/web-calls/dial | 30 |
POST …/phone/web-calls/accept · /rejoin · /decline | 60 each |
GET / PUT …/phone/my-status | 60 |
GET …/phone/presence | 120 |
GET …/phone/calls · GET …/phone/calls/{id} | 120 each |
GET …/phone/calls/{id}/recording | 30 |
GET …/phone/me | 120 |
GET …/phone/ws (socket handshakes) | 30 |
Server routes (pbxs_ key) — per key
| Route | Limit / min |
|---|---|
GET …/phone/server/calls | 240 |
GET …/phone/server/calls/export.csv | 6 |
POST …/phone/server/calls/originate | 60 |
GET …/phone/server/stats · /stats/groups | 60 each |
GET …/phone/server/presence | 240 |
GET / POST …/phone/server/seats | 60 |
Errors
Non-2xx responses are JSON with an error string; some carry a machine-readable code or message you can branch on:
json{ "error": "this key lacks the calls:originate scope", "code": "scope_missing", "scope": "calls:originate" }
| Status | When | What to look for |
|---|---|---|
400 | body invalid — e.g. neither a seat nor a session_token on a mint, to missing on a dial | error |
401 | missing / inactive key; session token missing, invalid or expired | error; a revoked session is "code": "session_revoked" — mint a new one |
403 | the pbxs_ key lacks the scope; the mint's origin is not on the key's allowlist; a session belongs to another key; a subscription to a non-phone.* type or non-webhook target | "code": "scope_missing" + scope, or "error": "origin not allowed for this key" |
404 | no active human extension for that external_user_id (or outside the key's pool); a call the seat was not on; unknown call / subscription / seat | error |
409 | originate when the seat has no open phone; a renewal for a seat whose extension was disabled | "message": "seat_offline" |
429 | rate limit hit | Retry-After header, retry_after seconds in the body |
503 | realtime unavailable (originate cannot reach the seat's phone) | retry later |
Security notes
- Origin allowlist + frame-ancestors. An embed key's allowed origins are enforced twice: at mint time (a mint from, or declaring, a foreign origin is
403) and by the browser — the console serves/embed/phonewith aContent-Security-Policy: frame-ancestorsbuilt from the session's key, so a page on any other host cannot show the phone at all. The widget socket refuses foreign origins at the handshake, too. - 15-minute JWTs with server-side revocation. A session token expires after 15 minutes and carries a
jtirecorded in a session ledger. Renewing rotates thejtiand closes the previous session;DELETE /api/v1/public/phone/session/{jti}(your logout), the console's session list, and disabling the key all revoke immediately — the next request answers401 session_revokedand the socket closes withsession.expired. Session tokens are refused on every console route and console tokens on every phone-embed route. - Keys are hashed.
pbx_andpbxs_keys are stored as a SHA-256 hash with a display prefix; the full value is shown once at creation and cannot be retrieved later. Send keys in a header, never a query string, and never to a browser. - Least privilege. A server key does only what its scopes allow; read scopes are on by default, every write or spend is an opt-in. Sessions are bound to one seat: they cannot see other seats' calls or recordings, or reach any console API.
- Everything is audited. Every session call and every server-key call is recorded with key, seat, verb, origin and IP — visible in the console under Phone System → Embed in other software → Show recent activity, and to administrators via
GET /api/v1/phone/embed-auditandGET /api/v1/phone/server-audit. - Recordings are never permanent URLs. Recording links are signed and short-lived (10 minutes for a seat, 1 hour for a server key or a webhook payload). Download and store the file, not the link.
Who can see a call's details and audio
| Through | Sees | Link |
|---|---|---|
Session token — GET /api/v1/public/phone/calls/{id}, …/recording | only calls that seat was on (its extension, or its number as caller/dialed); anything else is 404 | 10 min |
Server key — GET /api/v1/public/phone/server/calls/{id}, …/recording (recordings:read) | every call in the workspace, with summary and transcript turns | 1 hour |
ArcanFlows enforces the per-user rule on the session path; on the server path your CRM decides which of its users may open a recording it fetched tenant-wide. Links are signed and expire — treat one like the audio itself.
How a CRM user maps to an ArcanFlows user
Each extension belongs to an ArcanFlows user and carries an optional External user id. Mint sessions with {"external_user_id": "<your id>"}; ArcanFlows returns the extension carrying that id. Set the id either from the console on an existing extension (linking people who already have a seat) or by POST …/server/seats with id, email and name (seats:provision), which creates a phone-only identity and a new extension — for agents who only ever use the phone from your CRM.
Long calls, reloads and server-to-server requests
- Calls longer than 15 minutes are fine. The session token gates the API and the socket, not the audio; the media session is bounded by the call (4 h). Wire
onRenewTokenand renewal happens ~3 minutes before expiry without interrupting the call. If renewal fails, the server closes the socket at expiry and the phone ends the call after a 10 s grace. - Reload or close during a call shows the browser's leave prompt on your page (
guardUnload, default true). Leaving ends the operator's leg. - From your server, send a real
User-Agent. Cloudflare in front of the API answers403to library defaults such asPython-urllib/3.x;curl, Nodefetchand product strings pass.
Reference
- OpenAPI document (Swagger 2.0, 64 operations, no authentication, cached 5 minutes, readable cross-origin): https://<your-instance-api>/api/v1/public/phone/openapi.json
- Interactive reference — the same document rendered by tag, with parameters, request bodies and response models: Phone API Reference
- TypeScript types — generated from the spec and shipped in the repository at
web/console/lib/phone-embed/api-types.d.ts. To generate your own from the live document: convert it withswagger2openapiand runopenapi-typescripton the result (that is exactly whatnpm run gen:phone-typesdoes). - Widget test harness — a ready-made host page for the widget: /embed/phone-test.html on your console host. Paste a session token and watch the events.
- Widget loader —
https://app.arcanflows.com/embed/phone.js(or your console host),ArcanPhone.version1.0.0. - Guides (also answered by the in-console Platform Assistant): Using the API — keys, the session flow, the browser-side API, the widget,
pbxs_scopes and the caller-lookup hook (docs/guides/user/14-api.md); Phone Webhooks — the delivery and payload contract for everyphone.*event (docs/guides/user/25-phone-webhooks.md). - Related pages: Webhooks & Events API · Voice API · Rate Limits · Errors