Sonaline

Developers · Call-log API

Report your phone system's calls to Sonaline

Last updated 19 October 2026

Any phone system that can make an HTTPS request can report every call it handles, and upload the call's recording, to a Sonaline workspace. Sonaline files each call under the person whose line holds the extension that took it. Two endpoints, one bearer token, JSON in and JSON out.

Overview

A workspace connects a phone system on its Phone system page in Sonaline (Workspace › Calling › the phone system › Call log from this phone system › Connect). That page shows, once, three things your phone system needs:

Call events URLhttps://voice.sonaline.com/voice/pbx/crm/<workspace>/call
Recording upload URLhttps://voice.sonaline.com/voice/pbx/crm/<workspace>/recording
TokenA bearer token for this phone system, shown once.

<workspace> is the workspace's name in Sonaline (letters, digits and underscores); the page shows both URLs with it filled in. Your phone system then:

  1. sends one POST to the call events URL each time a call changes state: when it starts ringing, when it is answered, when it hangs up, or when nobody took it;
  2. after the call has hung up, uploads its recording, if there is one, to the recording upload URL.

Nothing is polled and nothing is pushed back to the phone system: the integration is write-only from your side.

Authentication

Every request carries the token in the Authorization header, never in the URL:

Authorization: Bearer <token>

A missing, malformed, wrong, revoked or foreign token gets one uniform answer, 401 {"error":"Missing or invalid token"}, whatever the reason. Do not retry a 401; fix the token.

Call events

POST the call events URL with Content-Type: application/json and a JSON object as the body. The body is at most 64 KiB (65,536 bytes). Send every field on every event, with null where the value is not known yet; Sonaline reads a call from all of its events together, so a field left null on one event is filled from another.

Three fields are required on every event: call_id, event and sequence. The rest may be null.

FieldTypeMeaning
eventstring, requiredOne of incoming, outgoing, answered, hangup, missed. Case does not matter. Anything else is a 400.
directionstring or nullinbound or outbound. When it is never given, a call with an outgoing event is outbound and any other call inbound.
call_idstring, requiredThe stable key of the call. The same value on every event of the call, through every leg and transfer. 1 to 128 characters from letters, digits, . _ @ : -. An Asterisk uniqueid such as 1760000000.123 fits. Two phone systems of one workspace may use overlapping ids: each phone system's calls are kept apart.
sequenceinteger, requiredThe ordering key. A whole number that increases across a call's events; microseconds since the epoch works well. Sonaline orders a call's events by sequence, never by arrival, so an event that arrives late still lands in its place. Together with call_id it also identifies the event for de-duplication (below).
linked_idstring or nullYour phone system's id for the call this one is linked to, if it has such a thing. Stored with the event; not interpreted today.
tenantstring or nullOn a multi-tenant phone system, the system's own name for the tenant the call belongs to. This is not the Sonaline workspace, which is named in the URL. Stored with the event; not interpreted.
caller_numberstring or nullThe calling party's number, as your system has it. The first non-empty value across the call's events is used.
caller_namestring or nullThe caller-id name, if any. A value with no letters in it (a number repeated) is treated as no name.
dialed_numberstring or nullThe number dialed: the called number of an inbound call, the destination of an outbound one. First non-empty value wins.
extensionstring or nullThe extension on your phone system that the call concerns: the one that answered, or that was rung. This is how the call is attributed to a person: a Sonaline line is placed on a phone system with an extension, and the call goes to the person whose line holds that extension there. null while nobody has answered yet and on a missed ring-group call. The last value any event names is used, so a transfer's last leg decides.
devicestring or nullThe endpoint that answered, when you know it: either an Asterisk channel name, PJSIP/<endpoint>-<id>, from which the endpoint is taken, or the bare endpoint name (up to 80 characters of letters, digits, _ . -). A name in any other form is ignored and the call is attributed by extension alone. See how calls appear for what the name decides. The last answered event's device is used.
queuestring or nullThe queue or ring group the call came through, if any. Shown in the workspace's call log.
ring_secondsnumber or nullHow long the call rang before it was answered or given up. When never given, it is computed from the events' timestamps.
duration_secondsnumber or nullTalk time, on the hangup event. When a call has several hangup events (a transfer), they are summed. When never given, it is computed from the answered and hangup timestamps.
ended_bystring or nullWho ended the call: extension or remote. Stored and shown.
recordingstring or nullThe recording file's basename (no directory), on the hangup event and only when the file exists. The file itself goes to the recording upload URL afterwards.
timestampstring or nullWhen the event happened, in UTC, ISO 8601 (2026-10-19T14:03:22Z). Set it once, when the event is first sent, and keep it unchanged on a retry. An event without a readable timestamp is dated by its arrival.
pbxstring or nullThe short hostname of the phone system sending this. Shown on the workspace's page as "Last event … from <pbx>".
backfillboolean, optionaltrue on an event replayed from the past, to fill in the call log of calls that happened before the hook was set up. Such a call is filed at its own timestamp like any other, but a missed one sends no missed-call notification and arrives already seen, so it does not raise the missed-calls badge. Leave it out on live calls.

Sonaline keeps the whole body of every event as sent, so a field it does not interpret today is not lost.

Event sequences

What each kind of call sends, in sequence order:

CallEventsNotes
Inbound, answeredincoming → answered → hangupextension and device become known at answered.
Inbound, missedincoming → missedA call that went to voicemail counts as missed. On a ring group, extension may stay null.
Outbound, answeredoutgoing → answered → hangupoutgoing and answered may be sent back to back when your system only learns of the call once it is up.
Outbound, not answeredoutgoing → hangupOptional; a phone system may leave such calls unreported. Reported, it shows as not answered.
Transfer… answered → hangup → answered → hangupSeveral answered and hangup events under one call_id. The last answered decides the extension and device; talk time is summed.
Ring group or queueas aboveOne stream of events per call, not one per phone that rang.

If your phone system only sees a call once it is bridged, send incoming (or outgoing) and answered back to back with increasing sequence values; Sonaline does not require a gap between them.

A call's state is read from its last event by sequence: a hangup or missed ends the call, which is then answered if any event answered it, else missed if a missed was sent, else not answered. Until an ending event arrives, the call is in progress.

Answers and retries

The call events URL answers with JSON:

StatusBodyMeaning
200{"status":"ok","call":"…","matched":"…"}Stored. call is Sonaline's id for the call; matched is the id of a call Sonaline's own calling had already recorded that looks like the same call, or null.
200{"status":"duplicate","call":"…"}An event with this call_id and sequence was already stored. Nothing was stored twice; the call was re-read from what is stored. Safe to treat as success.
400{"error":"…"}The body is not a JSON object, call_id is missing or malformed, event is not one of the five, or sequence is not a whole number. Do not retry; fix the request.
401{"error":"Missing or invalid token"}See Authentication. Do not retry.
413{"error":"…"}The body is over 64 KiB. Do not retry.
503{"error":"…"}Sonaline could not store the event just now. Please retry.

Idempotency

An event is identified by its call_id and sequence. Sending the same pair again is a no-op answered {"status":"duplicate"}. This is what makes retries safe: a request that timed out after Sonaline had processed it can be sent again as it was. Keep timestamp and sequence unchanged on a retry; do not mint a new sequence for a resend.

Recommended retry policy

Recording upload

After the call's hangup event, POST the audio file to the recording upload URL, with the same Authorization header and:

X-Call-IDRequired. The call's call_id, exactly as on its events.
Bodymultipart/form-data with exactly one part, named file, carrying filename="<basename>". The part must be the first part of the body. Alternatively, send the raw file as the body and name it in an X-File-Name header or a ?filename= query parameter.
Content-LengthSend the exact length. A declared length over the cap is refused before any byte is read.
StatusBodyMeaning
200{"status":"stored","id":"…","bytes":…}Filed. bytes is what was received.
200{"status":"exists","id":"…"}This file for this call was already filed.
400{"error":"…"}No or malformed X-Call-ID; no plain filename; the first part is not file; the part's headers run past 16 KiB; the body ended before the file or without its closing boundary; or the file is empty. Do not retry as sent.
401{"error":"Missing or invalid token"}See Authentication.
413{"error":"…"}Over 100 MiB. Do not retry.
503{"error":"…"}Storage was not available. Nothing was kept; you may retry.

Timing

Give a call event a timeout of 5 seconds and a recording upload 30 seconds; Sonaline answers within those budgets. A call event is acknowledged once it is stored. A recording is streamed to storage as it arrives, so a large file is bounded by your upload speed, not by Sonaline holding it.

Send a call's events in order when you can, but do not hold one back for another: ordering is by sequence, and a retry of an earlier event after a later one has landed is handled correctly.

Worked examples

Both examples use the workspace acme, a phone system called pbx1, extension 101 and numbers in the 555 range. Replace $TOKEN with the token from the Phone system page.

An inbound call that was answered, with its recording

The call starts ringing:

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/call \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "incoming",
    "direction": "inbound",
    "call_id": "1760000000.123",
    "linked_id": null,
    "tenant": null,
    "caller_number": "5555550123",
    "caller_name": "Jordan Rivera",
    "dialed_number": "5555550100",
    "extension": null,
    "device": null,
    "queue": "sales",
    "ring_seconds": null,
    "duration_seconds": null,
    "ended_by": null,
    "recording": null,
    "timestamp": "2026-10-19T14:03:10Z",
    "sequence": 1760882590000000,
    "pbx": "pbx1"
  }'
# {"status":"ok","call":"…","matched":null}

Extension 101 answers it, on the desk phone whose endpoint is 101-desk:

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/call \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "answered",
    "direction": "inbound",
    "call_id": "1760000000.123",
    "linked_id": null,
    "tenant": null,
    "caller_number": "5555550123",
    "caller_name": "Jordan Rivera",
    "dialed_number": "5555550100",
    "extension": "101",
    "device": "PJSIP/101-desk-0000a1b2",
    "queue": "sales",
    "ring_seconds": 12,
    "duration_seconds": null,
    "ended_by": null,
    "recording": null,
    "timestamp": "2026-10-19T14:03:22Z",
    "sequence": 1760882602000000,
    "pbx": "pbx1"
  }'

The call hangs up after 3 minutes and 5 seconds; the recording file exists:

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/call \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "hangup",
    "direction": "inbound",
    "call_id": "1760000000.123",
    "linked_id": null,
    "tenant": null,
    "caller_number": "5555550123",
    "caller_name": "Jordan Rivera",
    "dialed_number": "5555550100",
    "extension": "101",
    "device": "PJSIP/101-desk-0000a1b2",
    "queue": "sales",
    "ring_seconds": 12,
    "duration_seconds": 185,
    "ended_by": "remote",
    "recording": "140322-IN-sales-5555550123-101-1760000000.123.wav",
    "timestamp": "2026-10-19T14:06:27Z",
    "sequence": 1760882787000000,
    "pbx": "pbx1"
  }'

Then the recording itself:

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/recording \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Call-ID: 1760000000.123" \
  -F "file=@/var/spool/recordings/140322-IN-sales-5555550123-101-1760000000.123.wav;type=audio/wav"
# {"status":"stored","id":"…","bytes":2961644}

Sent again (for instance after a timeout), the same upload answers {"status":"exists","id":"…"} at once.

An inbound call that was missed

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/call \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "incoming",
    "direction": "inbound",
    "call_id": "1760000400.124",
    "linked_id": null,
    "tenant": null,
    "caller_number": "5555550199",
    "caller_name": null,
    "dialed_number": "5555550100",
    "extension": "101",
    "device": null,
    "queue": null,
    "ring_seconds": null,
    "duration_seconds": null,
    "ended_by": null,
    "recording": null,
    "timestamp": "2026-10-19T14:10:00Z",
    "sequence": 1760883000000000,
    "pbx": "pbx1"
  }'

curl -sS -X POST https://voice.sonaline.com/voice/pbx/crm/acme/call \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "missed",
    "direction": "inbound",
    "call_id": "1760000400.124",
    "linked_id": null,
    "tenant": null,
    "caller_number": "5555550199",
    "caller_name": null,
    "dialed_number": "5555550100",
    "extension": "101",
    "device": null,
    "queue": null,
    "ring_seconds": 25,
    "duration_seconds": null,
    "ended_by": "remote",
    "recording": null,
    "timestamp": "2026-10-19T14:10:25Z",
    "sequence": 1760883025000000,
    "pbx": "pbx1"
  }'

No recording follows a missed call. The call shows as missed, rang 25 s, for the person whose line holds extension 101 on pbx1.

How calls appear in Sonaline

A workspace puts a person's Sonaline line on a phone system with an extension. A reported call is attributed to the person whose line holds the call's extension on the phone system that sent it: one phone system's extension 101 and another's are different lines.

A recording is playable by the person the call is attributed to; a recording of a call under nobody is stored but not playable until a line holds its extension.

In each person's own call list (Recents, in the apps):

Privacy

The numbers and names in the events are the workspace's own call records, handled under the privacy policy: kept for the workspace's chosen call-retention period and deleted with the workspace. The token is stored only as a hash. Sonaline's service logs never carry the token, and a recording's filename is logged with its numbers masked.

Questions

Integrating a phone system Sonaline has not seen before? Write to hello@sonaline.com.