Developers · Calls API
Connect your phone system and CRM to Sonaline's calls
Last updated 6 November 2026
Connect your phone system or CRM to a Sonaline workspace: see the calls up on the workspace's lines, act on them (take a call over, transfer, park, hold, hang up), place a call from your CRM, follow and set presence and Do Not Disturb, read the call history and recordings, set lines up from a script, and hear about calls as they happen through signed webhooks. JSON in, JSON out.
Credentials and scopes
The Calls API uses the workspace's API credentials, the same ones as the Messaging API. A workspace admin makes them in the Admin Center (Advanced, API & webhooks, API credentials) and picks what each one may do:
| Scope | Lets the credential |
|---|---|
calls:read | see the calls up now |
calls:control | move a call (hold, transfer, park, take over, hang up) and place calls |
calls:override_dnd | place a call to a person on Do Not Disturb (force) |
recordings:read | list and download recordings, transcripts and summaries |
presence | see each line's status and set people's Do Not Disturb |
provisioning | list the phone systems and add, change or remove lines |
messaging | use the Messaging API |
A scope can be changed later in the same place; the change applies at once, to tokens already handed out too.
Sign in either way:
- A token:
POST https://api.sonaline.com/api/v1/oauth2/tokenwithAuthorization: Basic base64(client_id:client_secret)and the form bodygrant_type=client_credentials. The answer'saccess_tokenis good for one hour: send it asAuthorization: Bearer <token>. - Basic on each request:
Authorization: Basic base64(client_id:client_secret).
Keys go in headers only, never in a URL.
Every path starts with the workspace's account id, shown beside its credentials in the Admin Center:
https://api.sonaline.com/api/v1/accounts/{accountId}/...
A credential works on its own workspace's account only.
Two switches can stop a request whatever the credential's scopes: your
provider's API switch for the workspace (api_off), and the workspace's
own Allow call control through the API in the Admin Center, which
stops every move and every call placed (call_control_off), while reading
calls goes on working.
Errors
Every refusal has one shape:
{ "error": { "code": "scope_missing", "message": "This credential lacks the calls:control scope", "status": 403 } }
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid | A field is missing or wrong; field names it |
| 401 | unauthorized | No credential, or a wrong or expired one |
| 403 | forbidden | The account in the path is not the credential's |
| 403 | api_off | Your provider turned the API off for this workspace |
| 403 | scope_missing | The credential lacks the scope the request needs |
| 403 | call_control_off | The workspace turned call control through the API off |
| 403 | emergency | An emergency number: never dialed or transferred to through the API |
| 403 | recordings_not_shareable | The workspace does not let call recordings be saved or shared |
| 404 | not_found | No such call, line or person on this workspace |
| 405 | method_not_allowed | The path does not take that method |
| 409 | dnd | The person is on Do Not Disturb |
| 409 | conflict | The call or line is busy with something else |
| 415 | invalid | The body is not JSON |
| 422 | idempotency_mismatch | The Idempotency-Key was used on a different request |
| 429 | rate_limited | Too many requests; wait Retry-After seconds |
| 502 | box_unreachable | The phone service did not answer; try again |
| 503 | not_available | The feature is not set up on this service yet |
Rate limits
Per credential, per minute: 120 reads, 30 moves (and Do Not Disturb
changes), 10 calls placed, 30 line changes. Over a
limit the answer is 429 with Retry-After.
Idempotency
A POST may carry Idempotency-Key: <1 to 100 visible characters>. The
first answer is kept for 24 hours and given again, with
Idempotent-Replayed: true, to a repeat of the same request with the same
key from the same credential; nothing happens twice. Send one on every
call you place, so a retry after a timeout never rings a second time.
Live calls
GET /calls/live (scope calls:read) lists the calls up now on the
workspace's lines.
{
"calls": [
{
"id": "1762441262.42",
"line": { "id": "dev_7c1", "extension": "202", "name": "Front desk" },
"user": { "id": "u_19a", "name": "Ana Ruiz", "email": "ana@example.com" },
"direction": "inbound",
"state": "up",
"far": { "number": "+15551234567", "name": "ACME" },
"started_at": "2026-11-06T15:01:02.000Z",
"answered_at": "2026-11-06T15:01:09.000Z",
"duration": 73,
"parking_spot": null
}
],
"uncontrolled_lines": []
}
state is one of:
| State | Meaning |
|---|---|
ringing | not answered yet |
dialing | placed through the API; the person's devices are ringing |
up | talking |
held | on hold |
parked | parked, waiting for someone to pick it up |
room | in a conference |
far is the other party as the line's own person sees it in the app.
duration counts from the answer, in seconds. A line whose calls Sonaline's
switchboard does not carry has no live calls to show and is listed in
uncontrolled_lines.
GET /calls/live/{callId} answers one of them.
Moving a call
POST /calls/live/{callId}/actions (scope calls:control) with a JSON body:
action |
Other fields | Does |
|---|---|---|
hold | Puts the call on hold | |
unhold | Takes it off hold | |
park | Parks it; anyone on the workspace can pick it up | |
retrieve | line_id (optional) | Rings that line (else the call's own) with the parked call |
transfer | to | Blind transfer to an extension, a number or a feature code, dialed as the line |
attended | Joins the line's two calls to each other and leaves | |
takeover | Answers dial: a one-time code another device of the same line dials, within a minute, to take the call there | |
takeover | line_id | Rings another line of the workspace and hands it the call when it answers |
takeover | number | Rings one of the line owner's verified mobiles and hands it the call |
voicemail | Sends the caller to the line's voicemail | |
hangup | Ends the call | |
mute | channel, muted | Mutes a member of a conference |
Every target is checked against the workspace: a line must be the workspace's, a mobile must be one the line's owner verified in Sonaline, and an emergency number is refused. The answer names the call, the line and what the switchboard did:
{ "action": "takeover", "call": "1762441262.42", "line": { "id": "dev_7c1", "extension": "202", "name": "Front desk" },
"take": "1762441262.42", "dial": "*884810267731", "expires": 60 }
Every move is written to the workspace's activity log in the Admin Center, under the credential's name.
Placing a call (click-to-call)
POST /calls (scope calls:control):
{ "line_id": "dev_7c1", "to": "+15551234567" }
or "user_id" instead of line_id for the person's own line. Sonaline
rings the line's devices first (the app, a desk phone, the browser). When
the person answers, it dials to as that line, through the line's own
phone system, with its caller ID and dialing rules, and joins the two.
From then on it is a call like any other: it appears in /calls/live and
can be held, transferred or parked.
The answer is 202:
{ "id": "dial-4f2a9c01b7e3", "state": "dialing", "line": { "id": "dev_7c1", "extension": "202", "name": "Front desk" },
"user": { "id": "u_19a", "name": "Ana Ruiz", "email": "ana@example.com" }, "to": "+15551234567" }
Refused:
- the person is on Do Not Disturb:
409 dnd, unless the body says"force": trueand the credential hascalls:override_dnd; - an emergency number:
403 emergency, always; - calling is switched off for the workspace:
403 forbidden; - the line's calls are not carried by Sonaline's switchboard:
409 conflict.
Send an Idempotency-Key with every call you place.
Presence and Do Not Disturb
Scope presence.
GET /presence lists every line with its status and each person's Do Not
Disturb:
{
"lines": [
{ "id": "dev_7c1", "extension": "202", "name": "Front desk", "status": "on_call",
"registered": true, "enabled": true,
"user": { "id": "u_19a", "name": "Ana Ruiz", "email": "ana@example.com" } }
],
"dnd": { "u_19a": { "on": false, "from": "app", "until": null, "mode": "busy", "hours": null } }
}
status |
Meaning |
|---|---|
available | can take a call |
ringing | a call is ringing it |
on_call | on a call (talking, holding or in a conference) |
dnd | does not ring (Do Not Disturb) |
offline | switched off, or its phone system is not answering it |
GET /users/{userId}/dnd answers one person's Do Not Disturb.
PUT /users/{userId}/dnd sets it, exactly as the person's own switch in
the app does, on every line of theirs: {"on": true}, with "until" (a
time in milliseconds since 1970, within 31 days) for an end, and "mode"
busy (callers hear busy) or silent (nothing of theirs rings). Where
the line's phone system has its own Do Not Disturb codes entered, those
are dialed too, so desk phones follow.
PUT /users/{userId}/dnd/hours sets working hours, outside which the
person is on Do Not Disturb: {"days": [1,2,3,4,5], "start": "09:00",
"end": "17:00", "tz": "America/New_York"} (days 0 to 6 from Sunday).
DELETE the same path removes them.
Every change is written to the workspace's activity log.
Call history
Scope calls:read.
GET /calls lists the workspace's calls, newest first. Filters, all
optional: line_id, user_id, direction (inbound or outbound),
from and to (a date or an ISO 8601 time; to is exclusive),
missed=true or missed=false, and limit (1 to 200, default 50). The
answer has next_cursor: pass it as cursor for the next page, until it
is null.
{
"calls": [
{ "id": "a84b1c@sbc.sonaline.com", "line": { "id": "dev_7c1", "extension": "202", "name": "Front desk" },
"user": { "id": "u_19a", "name": "Ana Ruiz", "email": "ana@example.com" },
"direction": "inbound", "result": "answered", "missed_reason": null,
"far": { "number": "+15551234567", "name": "ACME" },
"started_at": "2026-11-06T15:01:02.000Z", "answered_at": "2026-11-06T15:01:09.000Z",
"ended_at": "2026-11-06T15:03:40.000Z", "duration": 151, "ring_seconds": 7, "continues": null }
],
"next_cursor": "WyIyMDI2LTExLTA2VDE1OjAxOjAyLjAwMFoiLCJhODRiMWMiXQ"
}
result is answered, missed, not_answered or in_progress.
continues names the call this one continued when it was taken over on
another device.
GET /calls/{callId} answers one call, with parties (who joined or left
it) and the ids of its recordings.
Calls older than the workspace's call history period are deleted, and are not in the API either.
Recordings
Scope recordings:read. The same filters and paging as the call history.
| Path | Answers |
|---|---|
GET /recordings | the recordings, newest first |
GET /recordings/{id} | one recording's facts, with transcript_ready and summary_ready |
GET /recordings/{id}/audio | the audio file |
GET /recordings/{id}/transcript | {id, transcript, turns}, or 404 when none was made |
GET /recordings/{id}/summary | {id, summary}, or 404 when none was made |
The audio is handed out only while the workspace's Members may share or
save call recordings is on; off, it is 403 recordings_not_shareable.
Each download is written to the workspace's activity log.
Provisioning
Scope provisioning. Lines are saved by the same rules as the Admin
Center's: only on the workspace's own phone systems, within the line and
device limits, the extension and device username free on that phone
system, the SIP password at least 16 characters, kept encrypted and never
answered back.
| Method and path | Does |
|---|---|
GET /pbxes | the workspace's phone systems: {id, name, host, host2} |
GET /lines | its lines, with person, extension, phone system, registration |
POST /lines | adds a line: {user_id?, pbx_id, extension, device_user, password, name?}; no user_id makes a line with no person yet |
PATCH /lines/{lineId} | changes name, enabled, password, extension, device_user, pbx_id, or gives a line with no person to user_id |
DELETE /lines/{lineId} | removes it |
POST /lines/import?pbx_id=… | adds up to 100 lines on one phone system |
An import takes JSON, {"lines": [{"extension", "password", "device_user"?,
"name"?, "email"?}]} (the person by their email), or CSV with a header
line naming those columns (Content-Type: text/csv). Add dry_run=true
to check every row, against the lines already on that phone system too,
without saving anything:
{ "dry_run": true, "would_add": 1, "skipped": 1, "results": [
{ "row": 0, "status": "would_add", "extension": "205" },
{ "row": 1, "status": "skipped", "extension": "202", "reason": "taken",
"message": "That extension or device user is already a line on this phone system" } ] }
Without it, each row answers added (with the new line's id) or
skipped and why. Every line added, changed or removed is written to the
workspace's activity log.
Webhooks
A workspace admin adds HTTPS endpoints in the Admin Center (Advanced, API & webhooks, Call webhooks), up to five, and picks the events each one hears:
| Event | When |
|---|---|
call.ringing | A call starts ringing a line |
call.answered | It is answered |
call.missed | Nobody answered; data.reason is no_answer, busy, dnd, declined or cancelled |
call.ended | An answered call hangs up |
call.transferred | A call was transferred (data.kind: blind or attended) |
call.taken_over | A call moved to another device or line (data.method: take or flip) |
voicemail.new | A voicemail arrived |
recording.ready | A call's recording is stored |
transcript.ready | A recording's transcript is ready |
summary.ready | A recording's summary is ready |
Each event is a POST to your endpoint:
{
"id": "evt_6b1d0c2e9f6a4c0f8a1b2c3d4e5f6071",
"type": "call.answered",
"created_at": "2026-11-06T15:01:09.000Z",
"account_id": "4821930",
"data": {
"call_id": "a84b1c@sbc.sonaline.com",
"line": { "id": "dev_7c1", "extension": "202", "name": "Front desk" },
"user": { "id": "u_19a", "name": "Ana Ruiz", "email": "ana@example.com" },
"direction": "inbound",
"far": { "number": "+15551234567", "name": "ACME" },
"started_at": "2026-11-06T15:01:02.000Z",
"answered_at": "2026-11-06T15:01:09.000Z",
"ended_at": null,
"duration": null
}
}
A payload never carries audio or a secret. A recording is named by its id,
data.recording_id, and data.links.recording is its path on this API,
which downloads it with a credential that has recordings:read.
Verifying a delivery
Every delivery carries:
Sonaline-Signature: t=1762441269,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Sonaline-Event-Id: evt_6b1d0c2e9f6a4c0f8a1b2c3d4e5f6071
Sonaline-Event-Type: call.answered
Sonaline-Delivery-Id: dl_3e0f8f1c2a9b4d7e8f6a5b4c
The endpoint's signing secret (whsec_...) is shown once when the
endpoint is added or its secret rotated. To check a delivery:
- Take
tand everyv1fromSonaline-Signature. - Refuse it when
tis more than 300 seconds from your clock (a replay). - Compute HMAC-SHA256 with the secret over
t + "." + rawBody, the body exactly as received, before any JSON parsing, as lowercase hex. - Accept it when that equals one of the
v1values, compared in constant time.
Node.js:
import crypto from "node:crypto";
function verify(header, rawBody, secret) {
const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]);
const t = Number(parts.t?.[0]?.slice(2));
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return (parts.v1 || []).some((p) => {
const got = Buffer.from(p.slice(3));
return got.length === want.length && crypto.timingSafeEqual(got, Buffer.from(want));
});
}
Python:
import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
fields = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in fields if k == "t"), "")
sigs = [v for k, v in fields if k == "v1"]
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, s) for s in sigs)
PHP:
function verify(string $header, string $rawBody, string $secret): bool {
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = $v; elseif ($k === 'v1') $sigs[] = $v;
}
if (!ctype_digit((string) $t) || abs(time() - (int) $t) > 300) return false;
$want = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $s) if (hash_equals($want, $s)) return true;
return false;
}
When you rotate a secret, the old one signs beside the new one for 24
hours (two v1 values), so you can switch without missing an event.
Answering, retries and the delivery log
Answer any 2xx within 10 seconds. Anything else, no answer, or a redirect
(never followed) is a failure, retried after 30 seconds, 2 minutes,
10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours: about a day
in all. The same event keeps its Sonaline-Event-Id on every try, so drop
one you have already handled. Events can arrive out of order; use
created_at.
The Admin Center shows each endpoint's last 100 deliveries (event, result,
your answer's status, attempts) and has Send test event, which posts a
ping at once and shows what your endpoint answered.
An endpoint whose deliveries have all failed for a day (at least ten in a row) is switched off, and the workspace's admins get an email. Turn it back on in the Admin Center once it is fixed.
Endpoint rules
An endpoint must be https on port 443, on a public host name: not an IP
address, not localhost or a local name, and not one of Sonaline's own
addresses.
Questions
Building an integration and need something the API does not do yet? Write to hello@sonaline.com.