Webhook Payloads
The complete JSON of every webhook event, a field-by-field table for each, and the full status and sub_status reference — every value the platform can send, and exactly what decides it.
Every event shares one 20-key envelope. all_processing_completed adds 27 more
fields for 47 total; each progress event adds a small, strictly additive delta.
No progress event carries a field the consolidated event does not
The consolidated payload is a superset of every other payload. One parser,
written against all_processing_completed, reads every event correctly.
A field's meaning never changes between events either — call_duration in
call_completed is the same field computed the same way as in
all_processing_completed.
The envelope
Present on every event. Every key is always present. A value we cannot
source is sent as explicit null — it is never omitted, so your parser never has
to distinguish "absent" from "unknown".
| Field | Type | Description | When null |
|---|---|---|---|
event_type | string | One of the event names in Event types. | Never |
event_id | string | Stable UUIDv5 derived from call_id and event_type. Identical across all four delivery attempts, and identical if the same event is ever emitted twice. This is your deduplication key. | Never |
version | string | Payload schema version. Currently the constant "1.0" for every event. | Never |
timestamp | string | When this payload was built. ISO-8601 UTC, always exactly three fractional digits, always Z: YYYY-MM-DDTHH:MM:SS.mmmZ. | Never |
call_id | string | The execution id — the same id POST /v2/calls returned and GET /v2/calls/{execution_id} accepts. | Never null. On a malformed record it is the empty string "", not null. |
call_sid | string | null | The carrier's own identifier for the call. | No carrier id was recorded (a call that failed before the carrier accepted it). |
agent_id | string | null | The agent you dialled — the original id, before any A/B resolution. Never the variant that actually served the call. | The record carries no agent id. |
agent_name | string | null | Human-readable agent name. | The name could not be looked up — the agent was deleted, or the lookup failed. A rename can take up to 5 minutes to show, and a failed lookup costs you the name, never the event. |
organization_id | string | null | Your organization. | The record carries no organization id. |
workspace_id | string | null | Always identical to organization_id. It exists for integrations whose schema expects a workspace id. | Whenever organization_id is null. |
status | string | The public call status. One of nine values — see Status. | Never. An unrecognised raw status becomes "failed", never a raw string. |
sub_status | string | null | The reason behind the status — see Sub-status. | Always null when status is queued, initiated, ringing or in_progress. |
call_type | string | null | Direction, typically "outbound" or "inbound" — "inapp_call" for a call Graine rang in your app. | Not recorded. |
calling_source | string | null | Always "custom" when a carrier carried the call. Graine does not name the carrier: which supplier routes a call is not part of this contract, and an integration must not branch on it. | Not recorded. |
custom_args_values | object | Your own call variables echoed back — see custom_args_values. | Never null. {} when you sent none. |
to_number | string | null | The number dialled, decrypted, in E.164. | Not recorded, or the stored value could not be decrypted. A value that is still ciphertext is discarded and this field becomes null — you will never receive an encrypted blob here. |
from_number | string | null | The caller ID presented. Same decryption guarantee. | Same as to_number. |
bulk_list_id | string | null | The batch this call belonged to. | A direct POST /v2/calls that was not part of a batch. |
called_on | string | null | When this attempt was dialled, from the contact's attempt history. | No timestamp available. See the caveat below. |
created_at | string | null | When the call record was created. Same ISO-8601 UTC format as timestamp. | Not recorded. |
called_on is dial time only for batch calls
For a call placed as part of a batch, called_on is the real per-attempt dial
time, matched to this specific call_id in the contact's attempt history.
For a direct-API call there is no contact record, so called_on falls back
to created_at — when the call record was created. For a call placed
immediately that is the moment you placed it; for a scheduled call it is
when you booked it, which can be hours or days before it rang. If you need
precise dial timing on direct calls, use call_duration and the terminal
timestamp instead.
Timestamps
Every timestamp in the payload — timestamp, created_at, called_on,
attempts[].attempted_at — is ISO-8601 UTC with three fractional digits and a
Z suffix.
One field breaks that rule on purpose:
platform_analysis.callback_time carries a local UTC offset, not Z,
because "call me back at 4pm" is meaningful only in the customer's own timezone.
It is passed through exactly as extracted and is never re-zoned. Read it
together with platform_analysis.timezone.
If a stored timestamp is unparseable, we forward the original string unchanged
rather than dropping the field — better a value you can inspect than a silent
null.
custom_args_values
Whatever call variables you supplied when placing the call, echoed back so your handler can route the result without a database lookup. Values pass through untouched, in whatever JSON type you sent.
Two classes of key are stripped before the payload is built:
1. Reserved keys. These are removed, because they duplicate — or contradict — a field of the envelope:
2. Pasted prose. A key that looks like a sentence rather than a variable name
is dropped — specifically, a key containing a ?, or made of five or more
whitespace-separated words, or longer than 40 characters. This exists because
prompt text pasted into a variable field would otherwise be echoed back as a JSON
key on every webhook.
Keep variable names short and identifier-shaped (policy_number,
renewal_date) and neither filter will ever touch them.
all_processing_completed
The default event, and the complete record: 47 fields.
Fields beyond the envelope
| Field | Type | Description | When null |
|---|---|---|---|
call_cost | float | null | Total cost of the call, written only when billing ran. | Never billed. null is not 0 — a zero-cost call reports 0.0, an unbilled call reports null. A call whose billing was deliberately suppressed (voicemail, for example) reports 0.0. |
call_cost_currency | string | ISO currency code for call_cost and cost_breakdown. Defaults to "USD". | Never |
cost_breakdown | object | null | Per-component cost. When present it always has exactly seven float keys: telephony, llm, network, platform, synthesizer, transcriber, total. A component we cannot parse is 0.0; total is the stored total, or the sum of the other six rounded to six decimal places — so the parts always add up to it, and to call_cost. | No breakdown was recorded. A breakdown carrying only the telephony cost is never stored, so a partial breakdown never reaches you. |
call_duration | float | null | Call duration in seconds, from the carrier. The carrier figure is authoritative; later enrichment may only lower it, never raise it. | No duration was recorded. |
agent_message_count | integer | null | Number of assistant turns in the transcript. | No transcript. null means "we could not see the conversation"; 0 would claim the agent said nothing, which is a different fact. |
user_message_count | integer | null | Number of user turns. | Same rule as above. |
overall_latency_seconds | float | null | Mean agent first-token latency across the call's turns, in seconds, 3 dp. Computed as the arithmetic mean of each turn's first-token latency (negative samples discarded), divided by 1000. This is the responsiveness number — how long the agent took to start speaking after the user stopped. | No per-turn latency was recorded, or the list was empty. |
first_utterance_seconds | float | null | When the agent delivered its welcome message, in seconds from the start of the conversation, 3 dp. | Not recorded. The user's first utterance is not measured and is deliberately not reported at all — do not infer it from this field. |
transcript | array | null | Ordered turns, each {"role": "assistant" | "user", "content": "..."}. | No transcript. Always null, never [] — except in one truncation edge case noted below. |
transcript_status | string | "available", "unavailable", or "truncated" (the body exceeded the size cap and turns were dropped from the end). | Never |
recording_url | string | null | Audio recording of the call. | No recording was produced or it has not been stored. |
recording_duration | null | Always null. Recording length is not measured. Use call_duration — but note it is the call duration, not the recorded audio length. | Always |
retry_count | integer | Which retry this call is. | Never null. 0 means "first attempt, or unknown" — a call whose attempt number was never recorded reports a literal 0. If you need certainty about attempt ordering, use retry_chain and attempts. |
attempts | array | null | Every dial attempt against this contact, oldest first. Item shape below. | null, not [], when the call has no contact record — i.e. a direct POST /v2/calls. [] would falsely claim the number was never dialled. |
parent_call_id | string | null | The call_id of the attempt this one retried. | This is the first attempt. |
retry_chain | array[string] | Every call_id in this retry lineage, oldest first. retry_chain[0] is the original call. | Never null. [] when there is no chain. |
platform_analysis | object | Always present, always 11 keys. See below. | Never |
platform_analysis_status | string | "completed" (summary and sentiment present), "partial" (one of them), "unavailable" (neither). | Never |
client_analysis | object | Your Custom Analysis variables, extracted from the transcript. Keys are the variable names you configured. Always an object, possibly {}. | Never null. |
client_analysis_status | string | "completed" (at least one variable extracted), "not_configured" (this agent has no Custom Analysis variables), "unavailable" (configured, but there was no transcript to extract from), "pending" (configured and a transcript exists, but extraction had not landed when the barrier expired). | Never |
tool_call_logs | array | null | The API tools the agent called during the call, in order — one entry per call carrying tool_name, url, method, status, response_status_code, response_content_type, response_body, response_json, latency_ms, headers (credential values redacted) and runtime_args (the arguments the model supplied). | No tool call was reported. null, never [] — an empty list would claim the agent called no tools, which a call that never reported cannot prove. |
collected_fields | object | null | A flow agent's collected fields: each field a path collected, by name, with the latest value, for example {"availability": "Yes"}. | Any other agent, or nothing was collected. null, never {}. |
answered_by_voice_mail | boolean | null | Whether an answering machine picked up. | The field was never set on the record. |
hangup_by | string | null | Who ended the call, as recorded when it ended: "Caller" (the person called hung up), "Callee" (no answer, busy, or a machine), "Agent" (the voice agent ended it — or never took it), "Provider" (the network failed or cancelled it), "Transfer" (handed to a person) or "system" (you stopped it). Passed through unchanged, casing included: display it, branch on sub_status. | Not recorded. |
ab_version_id | string | null | The A/B experiment that routed this call. | The call was not part of an experiment. |
ab_is_variant | boolean | null | true if the variant served this call, false if the control did. | The field was never set. |
processing_incomplete | boolean | true when the barrier timed out before every part of post-call processing arrived. | Never null. |
missing_fields | array[string] | What was missing when the barrier expired. Populated only when processing_incomplete is true. Possible values: "transcript", "cost", "recording", "followup_analysis", "client_analysis". | Never null. [] on a complete call. |
attempts[] item
| Field | Type | Description | When null |
|---|---|---|---|
call_id | string | null | The execution id of that attempt. | Not recorded for that attempt. |
status | string | null | The raw stored status of that attempt (e.g. "no-answer", "completed"). Note: this is the stored value, not the mapped webhook status of the envelope. | Not recorded. |
attempted_at | string | null | ISO-8601 UTC dial time for that attempt. | Not recorded. |
retry_count | integer | null | Which retry that attempt was. 0 is preserved and means a genuine first attempt — it is not coerced to null here. | Not recorded. |
from_number | string | null | Caller ID used, decrypted. | Not recorded, or the value could not be decrypted — ciphertext is discarded, never sent. |
If the contact record cannot be read, attempts degrades to a list derived from
retry_chain: every item has a real call_id and every other field is null.
You can tell this case apart because no item has an attempted_at.
platform_analysis
Always present, and every one of these 11 keys is always present inside it.
| Field | Type | Description | When null |
|---|---|---|---|
summary | string | null | Narrative summary of the conversation. | No summary was generated, or it could not be decrypted — you will never receive ciphertext here. |
call_disconnect_reason | string | null | The raw hangup detail, in its original casing, e.g. user_hangup, llm_prompted_hangup, voicemail_detected. This is the unmapped source value; the mapped, stable form is sub_status. Branch on sub_status, use this for debugging. | Not recorded. |
key_points | array | null | Bullet points from the call. | Permanently null for outbound campaign calls. Do not build against it. |
action_items | array | null | Next steps from the call. | Same permanent-null rule as key_points. |
callback_requested | boolean | null | true when the caller asked to be called back. | No follow-up analysis ran for this call. |
callback_time | string | null | When the caller asked to be called back. ISO-8601 with a local UTC offset, e.g. "2026-08-28T16:00:00+05:30" — passed through as extracted and never re-zoned, because the requested time is only meaningful locally. | No callback time was extracted. |
sentiment | string | null | Overall sentiment label, e.g. "positive", "neutral", "negative". | Sentiment analysis did not run. |
sentiment_score | float | null | Numeric sentiment score. | Sentiment analysis did not run. |
sentiment_analysis | string | null | Prose explanation of the sentiment verdict. | Not generated. |
tags | array[string] | Tags on the call. System tags — those prefixed origin:, category:, batch: or member: in any casing — are stripped, so you receive only meaningful labels. | Never null. [] when there are none. |
timezone | string | null | IANA timezone of the contact, read from the campaign. Pair this with callback_time. | Calls not placed by a campaign — a direct POST /v2/calls, for example — which have no campaign to read it from. |
client_analysis never carries your agent's configuration
client_analysis is the extracted values. The nested custom_analytics
key — which is the agent's Custom Analysis configuration, including its
extraction prompts — is stripped before the payload is built. Sending it would
leak your own prompt configuration to whatever consumes the webhook.
Progress events
Each carries the envelope plus the delta below, and nothing else. A dashboard Custom Webhook receives every one of them; an API subscription receives the event types it names.
The recipient's phone is ringing. This is the earliest live signal, and for a call nobody answers it is the only event that arrives while the call is still happening.
A Custom Webhook receives this automatically. An API subscription must name
call_ringing explicitly.
Status
status is our own closed vocabulary. A raw carrier status string is never
echoed into this field, and neither is any value outside the nine below.
The nine values
status | Meaning | Terminal |
|---|---|---|
queued | Accepted and waiting to dial — including scheduled and rescheduled calls. | No |
initiated | Handed to the phone network. | No |
ringing | The destination is ringing. | No |
in_progress | The conversation is live. | No |
completed | The call connected and ran to an end. This includes voicemail, transfers and agent errors — see sub_status for what actually happened. | Yes |
busy | The destination was busy. | Yes |
no_answer | Nobody picked up. | Yes |
failed | The call did not complete. | Yes |
canceled | The call was cancelled or stopped. Note the single l — US spelling, in both this value and the canceled sub-status token. | Yes |
Non-terminal set: queued, initiated, ringing, in_progress.
Failure set: busy, no_answer, failed, canceled.
completed does not mean successful
completed means the call was answered and the conversation ran. A transfer
to a human, the AI stack erroring mid-conversation, and some voicemail
pickups all produce status: "completed". The success or failure of the
conversation is in sub_status. Branch on both.
Voicemail arrives under two statuses — check sub_status, not status
An answering machine is recognised on more than one timeline, and the status differs:
- Caught when the call ends — the usual case:
status: "no_answer",sub_status: "voicemail_detected"(lowercase — failure family). - Recognised after the call was recorded as connected, before this event
was sent:
status: "completed",sub_status: "VOICEMAIL_DETECTED"(UPPERCASE — success family). The platform does not bill these calls. - Recognised after
all_processing_completedwas already sent: the call is rewritten tono_answer/voicemail_detectedandcall_correctedtells you so.
So there is exactly one reliable voicemail test:
sub_status.toUpperCase() === "VOICEMAIL_DETECTED". Branching on status
alone will count late-verdict voicemails as reached humans and early ones as
unanswered dials — both wrong for outcome reconciliation.
How the execution status becomes the webhook status
The status that GET /v2/calls/{execution_id} reports (see
Errors & Statuses) maps onto the nine webhook values
like this:
Execution status | Webhook status |
|---|---|
queued | queued |
scheduled | queued |
rescheduled | queued |
initiated | initiated |
ringing | ringing |
in-progress | in_progress |
completed | completed |
busy | busy |
no-answer | no_answer |
failed | failed |
error | failed |
call-disconnected | failed |
balance-low | failed |
canceled | canceled |
stopped | canceled |
The lookup is case-insensitive, and tolerates an underscore where a hyphen
belongs (no_answer still lands on no_answer). Anything that still does not
match becomes failed — a status we cannot interpret is treated as a failure,
never passed through raw and never silently dropped.
Sub-status
sub_status says why. Its casing is not decoration — it tells you which family
you are in:
status | sub_status casing | Example |
|---|---|---|
queued, initiated, ringing, in_progress | Always null | null |
completed | UPPERCASE | VOICEMAIL_DETECTED |
busy, no_answer, failed, canceled | lowercase | busy_here |
| anything unmodelled | lowercase | unknown |
Casing is a reliable discriminator
If sub_status is uppercase, the call connected. If it is lowercase, it did
not. You can branch on that alone before looking at the token itself.
Success sub-statuses (status = completed)
Eight values. They are resolved by an ordered chain and the first match wins —
the order is part of the contract, because a call can satisfy more than one rule.
A transfer that the agent then ended is TRANSFERRED, not AGENT_ENDED_CALL,
because transfer is checked first.
| Order | sub_status | Wins when |
|---|---|---|
| 1 | VOICEMAIL_DETECTED | The call was flagged as answered by voicemail, the hangup detail was a voicemail detection, or billing was suppressed for a voicemail reason — the late verdict, where the machine is recognised after the call connected and the call is not billed. Checked first so a voicemail is never reported as anything else. |
| 2 | MAX_CALL_DURATION | A web call hit its maximum duration ceiling. |
| 3 | INACTIVITY_TIMEOUT | The conversation was ended after a silence timeout. |
| 4 | AGENT_ERROR | The transcriber, synthesizer or language model errored during the call. |
| 5 | TRANSFERRED | The call was handed to a human — either the hangup was attributed to a transfer, or transfer data was recorded. |
| 6 | AGENT_ENDED_CALL | The agent decided the conversation was finished and hung up. |
| 7 | CUSTOMER_ENDED_CALL | The person on the other end hung up. |
| 8 | ACCEPTED | None of the above. The call connected and ended normally. This is the default success. |
AGENT_ERROR arrives with status: completed
When the AI stack fails mid-call, the carrier still recorded a connected,
completed call — and it is still billable, since audio flowed. So status
stays completed and the failure surfaces in sub_status as AGENT_ERROR.
This is deliberate: the webhook can never contradict what the dashboard and the
invoice say about the same call. If you count failed conversations, count
AGENT_ERROR among them.
Failure sub-statuses (status = busy, no_answer, failed, canceled)
Resolved in five steps, first match wins.
Step 1 — platform verdicts
These outrank every carrier signal, because we know something the carrier does not.
sub_status | Wins when |
|---|---|
voicemail_detected | The call was flagged as answered by voicemail, the hangup detail was a voicemail detection, or billing was suppressed for a voicemail reason — including a late voicemail verdict. |
insufficient_balance | The wallet could not fund the call. |
stopped_by_user | The call was stored as stopped. No call placed today is: a call you stop with POST /v2/calls/{execution_id}/stop reports canceled. |
canceled | The call was cancelled — including a live call you stopped. A scheduled call cancelled before it dialled sends no event at all. |
call_disconnected | The call was disconnected by the platform. |
platform_error | An error on our side terminated the call. |
agent_unavailable | The person answered and our voice agent never joined the call. Checked after the rows above, and before any carrier signal — the carrier reports such a call as a normal hang-up. It is our failure, not the number's. status is failed. |
Step 2 — carrier cause
Only reached if step 1 did not match. The decoding depends on how the carrier reported the call, and this is the single most misread part of the contract.
The carrier reported a real SIP response code, so it is decoded as one.
| SIP code | sub_status |
|---|---|
200–299 (the whole class) | normal_clearing |
403 | forbidden |
404 | not_found |
408 | request_timeout |
480 | temporarily_unavailable |
486 | busy_here |
487 | request_terminated |
500 | server_error |
502 | bad_gateway |
503 | service_unavailable |
600 | busy_everywhere |
603 | declined |
| any other code | sip_<code>, e.g. sip_484 |
sip_<code> is the only generated token in the whole sub-status vocabulary.
If you match sub-statuses against a fixed list, add a prefix rule for sip_ or
you will fall through on unusual carrier responses.
If no code was recorded at all, resolution falls through to step 3.
Step 3 — the reason table
Reached when no carrier code resolved. The stored hangup reason is normalised —
lowercased, whitespace collapsed, and a trailing (SIP NNN: ...) suffix stripped
— then matched exactly.
| Stored reason | sub_status |
|---|---|
call completed successfully | normal_clearing |
call ended from the customer's side | customer_ended_call |
user disconnected the call | customer_ended_call |
call completed by the assistant | agent_ended_call |
answering machine detected | voicemail_detected |
call transferred to human agent | transferred |
no answer from callee | no_answer |
callee was busy | busy_here |
call failed | call_failed |
call failed or was cancelled | call_failed |
These English strings are lookup keys only. They are never emitted — you will always receive the token in the right-hand column.
Step 4 — derived from the status
If nothing above matched, the status itself decides:
status | sub_status |
|---|---|
busy | busy_here |
no_answer | no_answer |
failed | call_failed |
canceled | canceled |
Step 5 — last resort
unknown.
The complete lowercase vocabulary
27 fixed tokens, plus the sip_<code> family:
do_not_call means the number is on the workspace's do-not-call list: nothing was dialled and nothing
was charged. It is not a failure, and no retry or follow-up acts on it.
unknown is reserved: it is the last resort for a status outside the nine,
and no call produces it today.
And the complete uppercase vocabulary, 8 tokens:
Null semantics — the ten rules worth pinning up
These are the distinctions that cost integration time when guessed at.
| Rule | |
|---|---|
| 1 | agent_name is best-effort: present when the name can be looked up, null when it cannot. Do not key on it — agent_id is the identifier. |
| 2 | recording_duration is always null. tool_call_logs is null unless the agent's tool calls were reported — never []. |
| 3 | platform_analysis.key_points and action_items are permanently null for campaign calls. |
| 4 | null is not 0 for agent_message_count, user_message_count and call_cost. null means unknown; 0 means measured as zero. |
| 5 | retry_count is the exception: never null, and 0 means "first attempt or unknown". |
| 6 | attempts is null — not [] — for direct-API calls with no contact record. |
| 7 | transcript is null, never []. retry_chain, tags, missing_fields and custom_args_values are always present containers and may be empty. |
| 8 | workspace_id is a copy of organization_id, not a second identifier. |
| 9 | agent_id is the original, pre-A/B-resolution id. |
| 10 | platform_analysis.callback_time carries a local offset. Every other timestamp is UTC with a Z. |
The one time transcript is an empty array
When a payload exceeds the size cap, transcript turns are dropped from the end
until it fits. If not even the first turn fits, you receive
"transcript": [] together with "transcript_status": "truncated". It is the
only case where transcript is [] rather than null, and
transcript_status always identifies it. Details in
Body size and truncation.

