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 URL | https://voice.sonaline.com/voice/pbx/crm/<workspace>/call |
|---|---|
| Recording upload URL | https://voice.sonaline.com/voice/pbx/crm/<workspace>/recording |
| Token | A 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:
- sends one
POSTto 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; - 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>
- One token per phone system. A workspace with several phone systems connects each one separately and gets a separate token for each. The token identifies the phone system within the workspace named in the URL; a token of another workspace's phone system does not open this workspace's URLs.
- Rotate: New token on the same page mints a replacement. The old token stops working at once, so put the new one in place right after.
- Revoke: Disconnect revokes the token. From then on both URLs answer
401; what the phone system already sent stays in the workspace. - Stored as a hash. Sonaline keeps only a SHA-256 of the token, so the page cannot show it again. If it is lost, mint a new one.
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.
| Field | Type | Meaning |
|---|---|---|
event | string, required | One of incoming, outgoing, answered, hangup, missed. Case does not matter. Anything else is a 400. |
direction | string or null | inbound or outbound. When it is never given, a call with an outgoing event is outbound and any other call inbound. |
call_id | string, required | The 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. |
sequence | integer, required | The 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_id | string or null | Your 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. |
tenant | string or null | On 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_number | string or null | The calling party's number, as your system has it. The first non-empty value across the call's events is used. |
caller_name | string or null | The caller-id name, if any. A value with no letters in it (a number repeated) is treated as no name. |
dialed_number | string or null | The number dialed: the called number of an inbound call, the destination of an outbound one. First non-empty value wins. |
extension | string or null | The 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. |
device | string or null | The 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. |
queue | string or null | The queue or ring group the call came through, if any. Shown in the workspace's call log. |
ring_seconds | number or null | How long the call rang before it was answered or given up. When never given, it is computed from the events' timestamps. |
duration_seconds | number or null | Talk 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_by | string or null | Who ended the call: extension or remote. Stored and shown. |
recording | string or null | The 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. |
timestamp | string or null | When 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. |
pbx | string or null | The short hostname of the phone system sending this. Shown on the workspace's page as "Last event … from <pbx>". |
backfill | boolean, optional | true 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:
| Call | Events | Notes |
|---|---|---|
| Inbound, answered | incoming → answered → hangup | extension and device become known at answered. |
| Inbound, missed | incoming → missed | A call that went to voicemail counts as missed. On a ring group, extension may stay null. |
| Outbound, answered | outgoing → answered → hangup | outgoing and answered may be sent back to back when your system only learns of the call once it is up. |
| Outbound, not answered | outgoing → hangup | Optional; a phone system may leave such calls unreported. Reported, it shows as not answered. |
| Transfer | … answered → hangup → answered → hangup | Several answered and hangup events under one call_id. The last answered decides the extension and device; talk time is summed. |
| Ring group or queue | as above | One 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:
| Status | Body | Meaning |
|---|---|---|
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
- Retry on no answer, a timeout,
408,429and any5xx, with backoff; spooling events to disk and retrying for up to 24 hours works well, and a late event still lands in order thanks tosequence. - Do not retry any other
4xx: it will fail the same way again. - Treat any
2xxas success. Reading the body is optional.
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-ID | Required. The call's call_id, exactly as on its events. |
|---|---|
| Body | multipart/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-Length | Send the exact length. A declared length over the cap is refused before any byte is read. |
- Only after hangup. The recording is filed with the call's span, parties and person, which are known once its events have arrived. A recording that arrives before its events is still kept, filed by what its filename says (
-IN-or-OUT-for the direction), under nobody. - Filename rules. A basename only: any directory part is stripped. 1 to 200 characters from letters, digits, space,
._@+-; no quotes. A name ending in.mp3, or a part sent asaudio/mpeg, is stored as MP3; anything else is stored as WAV. A name such as140322-IN-Q501-5555550123-101-1760000000.123.wavis typical. - Size cap: 100 MiB (104,857,600 bytes). A telephone-quality WAV (16 kB per second) reaches it at about 100 minutes of talk. Past the cap the answer is
413and nothing is kept; keep the file on your side. - De-duplicated by filename. The same filename for the same
call_idagain is answered{"status":"exists"}without reading the body, so an upload that times out can be sent again, and a transfer that uploads the same file twice costs nothing. A different filename for the same call is kept as a second recording of that call.
| Status | Body | Meaning |
|---|---|---|
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.
- When
devicenames the SIP username that Sonaline's own line uses on your phone system, the call was taken in Sonaline itself and is filed as that line's. - When it names any other endpoint, or is
null, the call was taken on a desk phone of the extension, and goes to the person whose line holds that extension. When two lines hold it, the older one gets the call. - When no line holds the extension, or no extension was ever named (a missed ring-group call), the call is still kept and shown to the workspace's administrators, under nobody.
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):
- A reported call that Sonaline itself also carried (the same direction, the same other party, the same extension or line, started within ten seconds) is one call: Sonaline's own record of it, with your recording when Sonaline made none and Sonaline's own when both exist. If your phone system delivered two distinct files for one call (one per leg of a transfer), both are listed. When Sonaline saw its own leg of the call cancelled while your phone system saw the extension's desk phone answer, the call shows as answered, with your talk time.
- A reported call Sonaline never carried (a call answered or missed on a desk phone) is a call of its own in the Recents of the person whose line holds the extension, marked Phone system, with your recording if you uploaded one. A missed one counts as a missed call for that person and notifies them once; nothing rings, since the desk phone already did.
- A reported call under nobody (no line holds its extension, or no extension was named) is shown only to the workspace's administrators, on the Phone system page under Calls the phone system reported, with how it was attributed (where every reported call is listed too).
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.