Events
An endpoint subscribes to exactly one event type. Three are available today.
| Event | Fires when |
|---|---|
call.completed | A call reached a terminal status. |
call_outcome.submitted | A disposition was recorded for a call — by an agent, or by a voice AI agent. |
qa_review.completed | A QA review you requested was scored, or failed. |
Names are hierarchical and full-stop delimited, so a future grouping mechanism can match on the call. prefix. New event types are added over time; a new one never changes an existing one's payload.
call_outcome.submitted is the one event guaranteed to fire — it comes from the agent's own action rather than from a telephony callback. If you need a reliable signal that a campaign call happened and what came of it, prefer it over call.completed. The one exception is opt-in on your operator's side: an interested lead their QA review disagreed with waits for a person, and may never be sent. See Leads held for QA.
A call passed between agents still produces exactly one call.completed, sent when the last party leaves rather than when the first agent drops off. call.id is stable across the handover, so a transferred call is one call to you, as it was to the customer.
The same holds when a voice AI agent puts a call through to a person at one of the campaign's transfer numbers: one call.completed, when the transferred conversation ends. Its agent.kind stays ai — the AI agent placed the call and nobody in your organization took it — and call.duration_sec covers both halves. The outcome arrives first, as transferred; see below.
Practice calls emit nothing. An agent can place a call to themselves to check their microphone and connection before a shift; it is a real call on our side, but it reaches no customer and no number is dialled, so it produces no call.completed and appears in no report. There is no field to filter on because there is no event.
Defined by WEBHOOKS.EVENT in src/lib/const/webhook.const.ts.
Namespaces
Every field is named namespace.leaf — call.id, contact.id, outcome.disposition. A namespace is one record, so you can map it onto one of your own; there is no field called call_id under call, and no flat data bag.
Three namespaces are ours and ride on every event, whatever it is about:
| Field | Nullable | Example |
|---|---|---|
event.id | No | "9a2f4c61-8b3d-4e52-a1c7-0f5b6d8e2a34" |
event.type | No | "call.completed" |
event.timestamp | No | "2026-08-17T09:15:00.000Z" |
endpoint.id | No | "b7c1f0e2-2f1a-4c5e-9f3b-6d2e8a4c1b90" |
endpoint.description | Yes | "Acme's CRM" |
delivery.id | No | "3f5e7c19-4a2b-4d68-b0e3-7c1a9f2d5b48" |
event.timestamp is when the event happened, not when this attempt was made.
event.id is shared by every delivery of one event, so it is what you use to collapse a fanout across several of your own endpoints. delivery.id is unique per attempt target and equals the webhook-id header — that is the one to deduplicate on. See Delivery.
The default body carries event and that event's own namespaces at the top level, with no wrapper around them. endpoint and delivery describe the delivery rather than the event, so they are reachable from a template and left out of the default body.
call.completed
Fires once a call is over. Carries the status, so one subscription covers every ending — narrow it with a filter if you only want some. The status says whether the call ended, was missed or failed on our side; how it went (answered, busy, voicemail) is call_outcome.submitted's.
Namespaces: call, org, campaign, contact, agent.
| Field | Type | Nullable | Example |
|---|---|---|---|
call.id | string (uuid) | No | "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25" |
call.status | string | No | "ended" |
call.direction | string | No | "outbound" |
call.from_e164 | string | No | "+27821234567" |
call.to_e164 | string | No | "+27829876543" |
call.duration_sec | number | Yes | 42 |
call.started_at | string (ISO) | Yes | "2026-08-17T09:14:00.000Z" |
call.answered_at | string (ISO) | Yes | "2026-08-17T09:14:10.000Z" |
call.ended_at | string (ISO) | Yes | "2026-08-17T09:15:00.000Z" |
org.id | string (uuid) | No | "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" |
campaign.id | string (uuid) | Yes | "7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1" |
contact.id | string (uuid) | Yes | "8b7a6c5d-4e3f-4a2b-9c8d-7e6f5a4b3c2d" |
contact.first_name | string | Yes | "Thabo" |
contact.last_name | string | Yes | "Mokoena" |
contact.email | string | Yes | "thabo.mokoena@example.co.za" |
contact.phone_e164 | string | Yes | "+27829876543" |
contact.status | string | Yes | "active" |
contact.dnc_at | string (ISO) | Yes | "2026-08-17T09:16:00.000Z" |
contact.source | string | Yes | "import" |
contact.created_at | string (ISO) | Yes | "2026-07-02T11:03:00.000Z" |
agent.kind | string | No | "human" |
campaign.id is null on every call that was not part of a campaign — which is most inbound calls. call.answered_at is null on a call that was never answered, and the timing fields are nullable together in the cases you would expect.
Every contact.* field on this event is nullable, and they are null together. A call reaches a person through its campaign, or — for a call placed by a voice AI agent from a contact's page — directly, with no campaign behind it. A voice AI agent working a campaign has both. An inbound call or a click-to-call has neither, so either the whole namespace is populated or all nine fields are null. Test contact.id and treat the rest as present or absent with it. That is why contact.phone_e164, contact.status, contact.source and contact.created_at are nullable here and not nullable on call_outcome.submitted: the namespace can be absent, which is a different thing from the value being empty.
agent.kind says who the call belonged to: ai when a voice AI agent placed it — whether or not the customer answered — and human otherwise. human means "not a voice AI agent", not "a person answered": an inbound call that nobody on your floor picked up is human too, since no machine was ever on it. Use call.answered_at to tell whether anybody spoke. The value is never null.
agent.kind was added after this event was first published. An endpoint created before it keeps sending exactly what it sent — add {{ agent.kind | json }} to its template to receive it. See Templates.
call.to_e164 is the number dialled and is not the same field as contact.phone_e164. On a transfer, or after somebody corrects the contact record, the two differ — and on an inbound call to_e164 is one of your own numbers. If you are reconciling a call against a person, you want both.
Filterable on call.status
status is always one of three terminal values:
| Value | Meaning |
|---|---|
ended | Every finished outbound call the customer's line decided — answered, busy, unanswered or a dead number alike — and every inbound call somebody answered. |
missed | An inbound call nobody answered. |
failed | Our side broke: the customer was never dialled, or a voice AI agent's customer answered and the agent never came on the line. |
These are the only values this event can carry — a non-terminal status like ringing never produces one. Filtering on a value outside this list is refused when you configure the endpoint. Until 22 October 2026 this event carried completed, busy, no_answer or canceled instead of ended and missed; an endpoint could only have filtered on those before that date.
Custom fields
Both events also expose your organisation's contact fields under a custom namespace, by each field's key — {{ custom.policy_no }} for a field keyed policy_no. They are not listed here because they are yours: the list is under Contacts → Fields, where each field's key is shown, and the template editor's Insert field menu lists them. A template naming a key that is not one of your fields is refused when you save. Values arrive as strings in the field's stored form (a date as 2026-11-01, a number as 1250.5). See Templates.
Two consequences worth knowing:
- Every custom field can be blank, always. No import guarantees a field is filled for every contact, so keep
| jsonbehind every one. - They are not in the default body. A new endpoint's generated template does not mention them; add the ones you want.
call_outcome.submitted
Fires when a disposition is recorded against a call — by an agent on the floor, or by a voice AI agent once its conversation has ended. A call the customer never answered is dispositioned too — no_answer, declined, busy, or invalid_number — from what the phone network reported, whether an agent or a voice AI agent placed it.
declined means the phone rang and the person turned the call down, or the network refused it for them; busy that the line was already in use before it rang. A person who answered and said they were busy is callback. hung_up means somebody answered and hung up during the opening, before saying who they are — a contact, but not the person you asked for. straight_to_voicemail is voicemail that never rang (usually a phone that was off), language_barrier a customer who could not follow the call in its language, and deceased that whoever answered said the person has died; that contact is not dialled again on any of your campaigns it was waiting on.
invalid_number means the phone network reported that the number does not exist (SIP 404, 410, 484 or 604), as opposed to a phone that was off, out of coverage or busy. That contact is not dialled again on the campaign. An agent on the floor can file it too, and the wrap-up form suggests it when the network gave that answer.
transferred means a voice AI agent put the customer through to a person at one of the campaign's transfer numbers during the call, and they were connected — a customer who asked for a person, a qualified customer on a campaign whose goal is Transfer to a person, or a supervisor's override. Only the system files it; an agent on the floor cannot. It is sent as soon as the AI agent's side of the call is processed — seconds after the AI left, usually while the transferred conversation is still going — with outcome.callback_at null, and it is never held for QA. That contact is not dialled again on the campaign. When nobody at the transfer numbers answers, the AI agent carries on and the call is dispositioned as any other.
Keep call.id on the lead in your CRM. When your sales team reaches the person, sells or loses the deal, report it back with POST /api/public/v1/lead-results and that call_id, and the result is tracked back to this call.
Namespaces: outcome, call, org, campaign, contact, script.
| Field | Type | Nullable | Example |
|---|---|---|---|
outcome.id | string (uuid) | No | "2d4f6a80-3b5c-4d7e-8f9a-0b1c2d3e4f50" |
outcome.disposition | string | No | "interested" |
outcome.callback_at | string (ISO) | Yes | "2026-08-20T08:00:00.000Z" |
outcome.submitted_at | string (ISO) | No | "2026-08-17T09:15:00.000Z" |
call.id | string (uuid) | No | "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25" |
org.id | string (uuid) | No | "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" |
campaign.id | string (uuid) | Yes | "7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1" |
campaign.name | string | Yes | "Solar leads — Gauteng" |
contact.id | string (uuid) | No | "8b7a6c5d-4e3f-4a2b-9c8d-7e6f5a4b3c2d" |
contact.first_name | string | Yes | "Thabo" |
contact.last_name | string | Yes | "Mokoena" |
contact.email | string | Yes | "thabo.mokoena@example.co.za" |
contact.phone_e164 | string | No | "+27829876543" |
contact.status | string | No | "active" |
contact.dnc_at | string (ISO) | Yes | "2026-08-17T09:16:00.000Z" |
contact.source | string | No | "import" |
contact.created_at | string (ISO) | No | "2026-07-02T11:03:00.000Z" |
script.id | string (uuid) | Yes | "4c6e8a02-5d7f-4a91-b3c5-8e0f2a4c6d81" |
script.name | string | Yes | "March renewals" |
script.version | number | Yes | 4 |
outcome.callback_at is populated when the disposition is callback, and on a campaign whose goal is Hand off to a person when it is interested and the customer named a time for that person to call (otherwise null). On every other disposition it is null. A callback always carries one: when the customer named no day or time, it is when they will be tried again — a few hours on, inside calling hours.
campaign.name is the campaign's name when the outcome was filed — a later rename does not reach an outcome already sent. It is null whenever campaign.id is, and on the rare outcome whose campaign could not be read at the time; key your system on campaign.id, not the name. It is not on call.completed.
campaign.id became nullable when voice AI agents shipped. It was previously documented as never null, because an agent only ever dispositions a call they claimed from a campaign. A voice AI agent placed from a contact's page dials that one contact directly and files its own disposition, so an outcome can arrive with no campaign behind it. A voice AI agent working a campaign carries the campaign's id like any agent on the floor.
Two consequences if you consume this event:
- A template reading
{{ campaign.id }}renders it empty on those calls rather than failing. Guard it if your system requires a campaign. - An endpoint filtered to specific campaigns will not receive the direct ones at all, exactly as such an endpoint already misses inbound
call.completed. Clear the campaign filter if you want them. It does receive a voice AI agent's outcomes on those campaigns — including theno_answer,declined,busyandinvalid_numberones above, which can be many more than a floor files. Filter onoutcome.dispositionif you only want the conversations.
The contact namespace is never absent here, which is why five of its fields are not nullable — a disposition is always about somebody.
contact.status is dnc when the contact's number is on your organization's do-not-call list, and active otherwise. contact.dnc_at is when the number first went on the list (of the entries still in force), and null for every active contact — which is most of them. On a do_not_call disposition the status already reads dnc: the opt-out is written in the same transaction as the outcome, and dnc_at is that moment unless the number was listed earlier.
contact.source is manual, import or api — how the record first arrived.
The script namespace
script describes the questionnaire the agent was reading, and all three fields are null together. A campaign need not have a script, and nor need a voice AI agent, so an absent namespace is ordinary rather than a fault — test script.id and treat the rest as present or absent with it.
script.version is the published revision the agent actually read, pinned to the call when it was claimed. It is not the script's current version: publishing a correction while a call is in progress does not change what that call served. It is also the only field on this event that is a JSON number rather than a string.
Two things worth knowing before you rely on it:
campaign.idcan be non-null whilescriptis entirely null. The namespace reports what the call served, and a campaign whose script could not be resolved when the contact was claimed served none. A voice AI agent's call serves the agent's own script, or none, whatever the campaign's is — so on a campaign worked by an AI agent,scriptcan name a different script from the campaign's, or be null.- A script's name is a label your operator can rename at any time. Join on
script.id; showscript.name.
outcome.notes is not sent, and that is a deliberate omission rather than one we have not got round to. It is free text an agent typed about a named person, the field most likely to hold something said in confidence. The disposition is the machine-readable answer your CRM needs. Transcript text — what the customer said, machine-transcribed — is excluded outright.
Script answers are the one deliberate exception, and the difference is mechanism rather than category. A free-text question can hold whatever the agent typed into it, so we make no claim that an answer is somehow less sensitive than a note. What differs is how it can reach you: outcome.notes would be a listed field, and every listed field is in the generated default body, so it would start flowing from every endpoint that pressed reset. An answer key cannot — there is no list for the generator to draw from, so each key is one you named on purpose.
Filterable on outcome.disposition
interested, not_interested, callback, dnq, language_barrier, do_not_call, transferred, hung_up, wrong_number, deceased, voicemail, straight_to_voicemail, no_answer, declined, busy, invalid_number
transferred was added in October 2026, and on 22 October 2026 dropped was split into hung_up and declined and language_barrier, deceased and straight_to_voicemail were added. No new dropped outcome is sent; events already delivered keep it, and the calls API reports those calls under their new disposition. An endpoint narrowed to particular dispositions does not receive a new one until you tick it; one set to "send every one" does. A consumer that matches dispositions exhaustively should treat a value it does not know as a new disposition, not an error.
qa_review.completed
Fires when a QA review you requested through POST /api/public/v1/qa/reviews ends: scored (review.status completed), or not going to be (failed, with review.error_code saying why). It is about a call on your own phone platform, so it carries no call, contact or campaign. An endpoint on this event cannot be narrowed by campaign.
The event is deliberately thin. A report's per-criterion scores, quotes and findings are lists, which a template's fields cannot carry, so fetch the full report with GET /api/public/v1/qa/reviews/{review.id}. review.external_id is the id you sent with the request, so you can match the event to your call without that fetch.
Each review sends this once, when it ends. A delivery may still arrive twice, as any can (Delivery), so key on event.id.
Namespaces: review, org.
| Field | Type | Nullable | Example |
|---|---|---|---|
review.id | string (uuid) | No | "3f6b2c1d-8e4a-4b7c-9d2e-1a0f5c6b7d8e" |
review.external_id | string | Yes | "call-8812" |
review.status | string | No | "completed" |
review.error_code | string | Yes | "review_failed" |
review.rubric_id | string (uuid) | No | "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d" |
review.score_pct | number | Yes | 84 |
review.completed_at | string (ISO) | No | "2026-08-17T09:16:30.000Z" |
org.id | string (uuid) | No | "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" |
review.score_pct is null on a failed review, and on one where every criterion was N/A. It is 0 when a criterion marked critical failed. review.error_code is null unless review.status is failed; its values are listed under QA reviews.
Filterable on review.status
completed or failed. An integration that only acts on scores can tick completed; one that retries failures can take both.
Leads held for QA
Your operator can have interested outcomes wait for their automatic QA review before call_outcome.submitted is sent — so a lead the review thinks was filed wrongly does not reach your system. It is off unless they switch it on. When it is on:
- Only calls the review picks wait. The review scores a sample of calls, and only an
interestedoutcome on one of those is held. Every other call, and every other disposition, is sent as before. - It is sent as soon as the review has read the call, usually a few minutes after the call ends. If no review is going to run — no recording, a transcription that failed, a review that failed, the operator's daily limit reached — it is sent at once.
- It never waits more than 5 minutes after the outcome was filed, whatever the review is doing.
WEBHOOKS.HOLD.MAX_SECinsrc/lib/const/webhook.const.ts. - If the review disagrees with the disposition, the event is withheld until somebody at your operator checks the call and releases it by hand, which may be never. A review that agrees, or that cannot tell from the call, sends it.
A held event is exactly what the agent filed. Its fields describe the moment the outcome was recorded, not the moment it was sent: outcome.submitted_at, event.timestamp and x-event-time are all the filing time, while webhook-timestamp is when we made the attempt. Your endpoint's filters, template and custom headers are the ones in force when it is sent.
There is no field saying an event was held, or what the review concluded.
Script answers
The answers your agents captured on the call are reachable under an answers namespace, keyed on the question's own field key — {{ answers.renews_on }} for a question saving to renews_on. Like custom fields, these are not listed here: they are your own question keys and differ per script and per published version. The template editor lists the ones your endpoint's campaigns ask, and a save refuses any other (see templates). A call only carries answers to the questions its branch actually reached.
Available on call_outcome.submitted only. call.completed fires from a telephony callback that can arrive while the agent is still typing, so answers there would be quietly incomplete.
Five rules:
- Every answer is a JSON string, always — including a number question. A question typed as a number delivers
"250", not250. This is the mistake worth checking your intake for first. - Every answer can be blank. A question on a branch the conversation never took has no answer at all, and a missing key renders as
null, so keep| jsonbehind every one. - They are opt-in. A new endpoint's generated template does not mention them; name the keys you want, or send every answer with
{{ answers | json }}. - An empty answer set is indistinguishable from "the agent answered nothing." If knowing the difference matters, check
script.id: null means no script was served at all. - A key containing a dash, or starting with a digit, needs bracket form —
{{ answers["policy-no"] }}. The editor writes that for you.
Subtype filters
Each event has exactly one filterable dimension — the field named above. You tick the values you want; membership only, no operators and no negation. To exclude one value, tick the rest.
Two things about filters that are easy to get wrong:
"Send every one" is its own choice, not an empty list. Leaving every box unticked is refused; to receive everything, choose "send every one". That choice also includes any value we add later, which ticking every box today does not.
A suppressed event leaves no row. An event outside your filter is never sent and writes no row in the delivery log. So a correctly narrowed endpoint and a completely broken one look identical on that page — check the filter before concluding the endpoint is broken. The event itself is kept, so widening the filter and resending recovers it.
The call events' two dimensions share no value: a call's status says only that it is over, and whether it was busy or unanswered is its disposition. An endpoint only ever offers its own event's values.
Test deliveries
The dashboard can send a test delivery to an endpoint. A test body is shaped exactly like a real one — event.type renders the endpoint's real event type — so the only way to tell them apart is the x-event-test header, which is present on a test and absent (not false) on a real delivery.
If acting on a test event would be harmful, check for that header.