Sonaline

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:readsee the calls up now
calls:controlmove a call (hold, transfer, park, take over, hang up) and place calls
calls:override_dndplace a call to a person on Do Not Disturb (force)
recordings:readlist and download recordings, transcripts and summaries
presencesee each line's status and set people's Do Not Disturb
provisioninglist the phone systems and add, change or remove lines
messaginguse 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:

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
400invalidA field is missing or wrong; field names it
401unauthorizedNo credential, or a wrong or expired one
403forbiddenThe account in the path is not the credential's
403api_offYour provider turned the API off for this workspace
403scope_missingThe credential lacks the scope the request needs
403call_control_offThe workspace turned call control through the API off
403emergencyAn emergency number: never dialed or transferred to through the API
403recordings_not_shareableThe workspace does not let call recordings be saved or shared
404not_foundNo such call, line or person on this workspace
405method_not_allowedThe path does not take that method
409dndThe person is on Do Not Disturb
409conflictThe call or line is busy with something else
415invalidThe body is not JSON
422idempotency_mismatchThe Idempotency-Key was used on a different request
429rate_limitedToo many requests; wait Retry-After seconds
502box_unreachableThe phone service did not answer; try again
503not_availableThe 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
ringingnot answered yet
dialingplaced through the API; the person's devices are ringing
uptalking
heldon hold
parkedparked, waiting for someone to pick it up
roomin 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
holdPuts the call on hold
unholdTakes it off hold
parkParks it; anyone on the workspace can pick it up
retrieveline_id (optional)Rings that line (else the call's own) with the parked call
transfertoBlind transfer to an extension, a number or a feature code, dialed as the line
attendedJoins the line's two calls to each other and leaves
takeoverAnswers dial: a one-time code another device of the same line dials, within a minute, to take the call there
takeoverline_idRings another line of the workspace and hands it the call when it answers
takeovernumberRings one of the line owner's verified mobiles and hands it the call
voicemailSends the caller to the line's voicemail
hangupEnds the call
mutechannel, mutedMutes 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:

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
availablecan take a call
ringinga call is ringing it
on_callon a call (talking, holding or in a conference)
dnddoes not ring (Do Not Disturb)
offlineswitched 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 /recordingsthe recordings, newest first
GET /recordings/{id}one recording's facts, with transcript_ready and summary_ready
GET /recordings/{id}/audiothe 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 /pbxesthe workspace's phone systems: {id, name, host, host2}
GET /linesits lines, with person, extension, phone system, registration
POST /linesadds 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.ringingA call starts ringing a line
call.answeredIt is answered
call.missedNobody answered; data.reason is no_answer, busy, dnd, declined or cancelled
call.endedAn answered call hangs up
call.transferredA call was transferred (data.kind: blind or attended)
call.taken_overA call moved to another device or line (data.method: take or flip)
voicemail.newA voicemail arrived
recording.readyA call's recording is stored
transcript.readyA recording's transcript is ready
summary.readyA 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:

  1. Take t and every v1 from Sonaline-Signature.
  2. Refuse it when t is more than 300 seconds from your clock (a replay).
  3. Compute HMAC-SHA256 with the secret over t + "." + rawBody, the body exactly as received, before any JSON parsing, as lowercase hex.
  4. Accept it when that equals one of the v1 values, 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.