Skip to main content
Arcanflows

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.

CredentialLooks likeLifetimeWhere it livesWhat it can do
Embed keypbx_…until you disable it (optional expiry date)your backend only — never a browsermints and revokes browser session tokens (POST / DELETE /api/v1/public/phone/session)
Session tokenJWT, 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 UIthe browser-side phone API under /api/v1/public/phone/…: ring, answer, dial, hold, transfer, conference, presence, own call history, realtime socket
Server keypbxs_…until you disable ityour backendtenant-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 is 403, and the same list becomes the widget page's frame-ancestors policy, 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-keys with a console JWT and the phone_embed.manage permission (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.

bash
curl -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:

EventPayloadWhen
readyseat detailsthe phone is connected and can take calls
ringcall_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_startedcall_id, directiona call connected
call_endedcall_id, reasona call ended
status_changedthe seat's new statusthe seat's status changed
presence_changeddirectory presencea 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
errormessage, detaile.g. token renewal failed
chat_receivedconversation_id, message_id, sender_id, sender_name, preview, mention, threada colleague wrote in team chat (key's Team chat switch on)
chat_unreadtotal, mentionsthe 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:

javascript
document.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:

bash
curl -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):

bash
curl -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:

javascript
import 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:

bash
curl -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

RouteLimit / 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.json60
GET /api/v1/public/embed/frame-policy240

Browser-side routes (session token) — per seat

RouteLimit / min
POST …/phone/web-calls/dial30
POST …/phone/web-calls/accept · /rejoin · /decline60 each
GET / PUT …/phone/my-status60
GET …/phone/presence120
GET …/phone/calls · GET …/phone/calls/{id}120 each
GET …/phone/calls/{id}/recording30
GET …/phone/me120
GET …/phone/ws (socket handshakes)30

Server routes (pbxs_ key) — per key

RouteLimit / min
GET …/phone/server/calls240
GET …/phone/server/calls/export.csv6
POST …/phone/server/calls/originate60
GET …/phone/server/stats · /stats/groups60 each
GET …/phone/server/presence240
GET / POST …/phone/server/seats60

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" }
StatusWhenWhat to look for
400body invalid — e.g. neither a seat nor a session_token on a mint, to missing on a dialerror
401missing / inactive key; session token missing, invalid or expirederror; a revoked session is "code": "session_revoked" — mint a new one
403the 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"
404no active human extension for that external_user_id (or outside the key's pool); a call the seat was not on; unknown call / subscription / seaterror
409originate when the seat has no open phone; a renewal for a seat whose extension was disabled"message": "seat_offline"
429rate limit hitRetry-After header, retry_after seconds in the body
503realtime 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/phone with a Content-Security-Policy: frame-ancestors built 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 jti recorded in a session ledger. Renewing rotates the jti and 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 answers 401 session_revoked and the socket closes with session.expired. Session tokens are refused on every console route and console tokens on every phone-embed route.
  • Keys are hashed. pbx_ and pbxs_ 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-audit and GET /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

ThroughSeesLink
Session token — GET /api/v1/public/phone/calls/{id}, …/recordingonly calls that seat was on (its extension, or its number as caller/dialed); anything else is 40410 min
Server key — GET /api/v1/public/phone/server/calls/{id}, …/recording (recordings:read)every call in the workspace, with summary and transcript turns1 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 onRenewToken and 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 answers 403 to library defaults such as Python-urllib/3.x; curl, Node fetch and 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 with swagger2openapi and run openapi-typescript on the result (that is exactly what npm run gen:phone-types does).
  • 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.version 1.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 every phone.* event (docs/guides/user/25-phone-webhooks.md).
  • Related pages: Webhooks & Events API · Voice API · Rate Limits · Errors