Call ingestion is the one write door for call history. A reporter posts what the phone system saw, and everything downstream, the popup on the front desk and the record in the clinic's history, follows from it.
POST /api/v1/calls/eventsThe endpoint is provider agnostic. Every phone system reports into the same vocabulary, and the route never learns which one it is serving. A new phone system is a translator into this body, never a second copy of this endpoint. For the SIP reporter's own detail, see SIP calls.
What you need to call it
A key federated to an organization, holding org.calls.events. The key is the clinic, so the body never names one.
org.calls.events is a wildcard, so it covers every event type beneath it. A reporter licensed for only some of them can hold the per-event scopes instead, org.calls.events.call_received and so on.
A batch is all or nothing. A reporter licensed for eight of its nine event types is told so, rather than having eight quietly accepted and the ninth disappear into a history nobody audits.
The body
One event on its own, or a batch.
{ "events": [ { "type": "call_received", "...": "..." } ] }A single event is the hot path: call_received is the popup, and there is no reason to make a reporter wrap the thing it is racing to send. Batches are for the tail, where an ended event, its recording and its transcript usually surface together. Fifty events is the ceiling.
Every event repeats the whole call
This is the one thing to understand before writing a reporter. Every event carries the complete call block, not just a reference to it.
Events arrive out of order, and sometimes the first one never arrives at all. An event that only said "call abc123 was answered" would be useless whenever nothing had created abc123. Repeating the block means any event can create the call, and the order they land in stops mattering.
Envelope
Present on every event, whatever its type.
| Field | Type | Notes |
|---|---|---|
type | string, required | One of the nine event types below. |
providerCallId | string, required | The provider's own handle for this call. Every event names it, so the moments of one call collapse onto one record however they interleave. |
occurredAt | ISO 8601, required | When the moment happened, on the reporter's clock. Not when the report arrived. |
call | object, required | Who was on the call and how it was routed. See below. |
provider | string | Which phone system is reporting. Defaults to sip. With providerCallId, the key the call is filed under. |
sequence | integer | The reporter's counter for this call. Ordering only. Defaults to 0. |
providerEventId | string or null | The provider's own id for this event, where it mints them. Identity, which is a different fact from order. Defaults to null. |
observer | enum | How much of the call this reporter can see: endpoint, proxy, or pbx_api. Defaults to endpoint. |
locations | string[] | Which workstation groups to wake. Defaults to ["main"]. |
raw | object or null | The wire record: the SIP message, provider headers, whatever the reporter holds. Never parsed, kept so a quirk found in six months is diagnosable from history. |
It defaults to ["main"], so a reporter that ignores it will pop every workstation in the default group. On a practice with more than one site, that is the wrong desks rather than an error, which is a worse failure than a 400.
The call block
| Field | Type | Notes |
|---|---|---|
direction | enum, required | inbound, outbound, or internal. |
from, to | party | Each end of the call. |
trunk | string or null | The carrier trunk it arrived on. |
diversion | array | The forwarded-from chain, outermost first. |
A party is kind (e164, extension, anonymous, or unknown, defaulting to e164), plus number, name, extension and uri, all nullable.
kind is what separates a caller who withheld their number from a parse failure. anonymous is a kind of caller, not an error, and it still gets a popup: the practice simply sees no name.
Event types
| Type | Carries, beyond the envelope |
|---|---|
call_received | Nothing. The ringing phone, and the only event that produces a popup. |
call_placed | Nothing. |
call_missed | Nothing. |
call_answered | answeredBy, the extension that picked up, where the reporter can see it. |
call_ended | disposition, duration, talkTime, answeredElsewhere, sipCode, sipReason. |
call_hold | released. False puts the call on hold, true takes it off. |
call_transferred | to, and mode: blind or attended. |
call_recorded | url, mimeType, bytes, duration. |
call_transcribed | text, language, confidence. |
What happens to a report
Worth knowing, because it explains the response and the retry rules.
- The events are grouped by call and sorted by
sequence, then by the reporter's clock. A batch carrying the end of one call beside the ringing of the next costs one write per call, not one per event. - A
call_receivedtriggers the caller lookup, asked of the practice's own client system. Every other event type skips it: a hold arriving twenty minutes into a call has no reason to ask anything. - The popup is published before anything is persisted. The popup is the product, and nothing in it needs a write to have happened. A database that is briefly slow must not become a phone that rings with nobody's name on it.
- Then the record is written, the call upserted and the events appended.
Two failures are deliberately survivable. A client system that refuses, times out, or has been redesigned under us produces an unknown caller, never a failed call: the number still goes out, only the name is missing. And a broker that is down is a missed popup, never a lost call.
Identity, duplicates and ordering
The call
A call is keyed on the organization, provider and providerCallId, and upserted. Any event can create it, and every later event finds it.
The event
An event is deduplicated within its call on providerEventId when the provider mints one, and on type:sequence otherwise.
That fallback is what most reporters will use, and it has one consequence: a provider that can fire the same event type twice in one call, and does not count for us, must fill in providerEventId, or its second event is dropped as a duplicate of its first. call_hold is the case, which is exactly why the field exists.
Ordering
Events arriving out of order is ordinary, not exotic. A call_ended beating its call_received happens routinely.
The call's state therefore only ever climbs a ladder:
unknown < ringing < answered < endedcall_received and call_placed mean ringing, call_answered means answered, call_ended and call_missed mean ended. Everything else, a hold, a transfer, a recording, a transcript, does not move the state at all: those happened during a state rather than being a new one.
A late ringing event therefore cannot blank the end of a call that already hung up.
Clocks
occurredAt is trusted, but never into the future. A stamp ahead of the server is clamped to now, because a call list ordered by that stamp would otherwise pin one machine's bad clock to the top of a clinic's history and keep it there. Nothing is lost: the event's whole body, the reporter's own stamp included, is kept verbatim.
The response
{
"success": true,
"calls": [
{
"id": "cm1f8q0a70000abcd1234wxyz",
"providerCallId": "demo-0001",
"created": true,
"crmClients": [ { "externalId": "8812", "name": "Jane Dolittle" } ]
}
],
"events": [
{
"id": "cm1f8q0a80001abcd5678wxyz",
"type": "call_received",
"dedupeKey": "call_received:0",
"duplicate": false
}
]
}201 when the request created a call, 200 when every call in it already existed.
duplicate is the field a reporter should watch. It is how a client that timed out and retried tells a landed retry from a silent failure, and it is why the write is awaited rather than deferred: a reporter needs to learn its retry landed.
Retries
Retrying is safe. Resend the identical body. The call upserts, the events deduplicate, and the response comes back with duplicate: true on anything already on file.
There is no published rate limit. The backpressure signal is a 503 with Retry-After, which means the server is briefly overloaded and the same request is safe to send again. Batch the tail of a call rather than firing one request per event, and keep the ringing event on its own.
A whole call, reported
Three requests, in the order they happen. The call block repeats every time.
{
"type": "call_received",
"providerCallId": "a1b2c3",
"sequence": 0,
"occurredAt": "2026-09-04T14:32:05.120Z",
"locations": [ "front-desk" ],
"call": {
"direction": "inbound",
"from": { "kind": "e164", "number": "+15550104242", "name": "DOLITTLE JANE" },
"to": { "kind": "extension", "extension": "101" }
}
}{
"type": "call_answered",
"providerCallId": "a1b2c3",
"sequence": 1,
"occurredAt": "2026-09-04T14:32:11.400Z",
"answeredBy": "101",
"call": {
"direction": "inbound",
"from": { "kind": "e164", "number": "+15550104242", "name": "DOLITTLE JANE" },
"to": { "kind": "extension", "extension": "101" }
}
}{
"type": "call_ended",
"providerCallId": "a1b2c3",
"sequence": 2,
"occurredAt": "2026-09-04T14:36:48.900Z",
"disposition": "answered",
"duration": 283,
"talkTime": 277,
"call": {
"direction": "inbound",
"from": { "kind": "e164", "number": "+15550104242", "name": "DOLITTLE JANE" },
"to": { "kind": "extension", "extension": "101" }
}
}Had these arrived in the reverse order, the record would be identical. The first to land creates the call, and the state climbs to ended and stays there.