Webhook Payloads
The complete JSON of all eight webhook events, a field-by-field table for each, and the full status and sub_status reference — every value this engine can emit, 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 all eight events 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 all eight events. 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 eight event names. | 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 telephony provider's own call identifier. | No provider 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. | Populated from the agent directory, which reads the agent document and prefers agent_name over the legacy name. null on a lookup miss — no such agent, a document carrying neither field, or a directory outage. The lookup is cached for 5 minutes and payload construction never fails on it, so a rename can take up to 5 minutes to show up and an outage costs you the name, not the event. |
organization_id | string | null | Your organization. | The record carries no organization id. |
workspace_id | string | null | Always byte-identical to organization_id. This platform has no workspace concept; the key exists for consumers whose schema expects it. | 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". | 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. There is no dedicated dial-time or answer-time field anywhere
in the pipeline, so for direct calls this is effectively "when we first heard
about the call", not "when the phone started ringing". If you need precise
billing-grade 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. Platform plumbing. These keys are internal and are removed verbatim, because they duplicate — or contradict — a first-class envelope field:
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 six float keys: 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 five rounded to six decimal places. | No breakdown was persisted. A telephony-only stub is refused rather than 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 measurable in this pipeline 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 reported by any producer in this pipeline. 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" — records created from a webhook alone carry a literal 0 that nothing later updates. 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 | null | Always null. Tool invocation history is not carried through this pipeline. It is null rather than [] on purpose, so "no tools were called" stays distinguishable from "not available" if this is ever populated. | Always |
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. Common values: "caller", "callee", "agent", "transfer". | 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 and one or more producers never reported. | 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 public 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 internal 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. This field is populated only by a screening flow that campaign calls do not go through. 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. Internal 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. | Direct-API and app calls, which have no campaign to read it from. The timezone used at dispatch does not survive the round trip. |
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. Shipping 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 or internal status string is
never echoed into this field.
The nine values
status | Meaning | Terminal |
|---|---|---|
queued | Accepted and waiting to dial — including scheduled and rescheduled calls. | No |
initiated | Handed to the telephony provider. | 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 two timelines, and the status differs:
- Caught early, before the conversation counted:
status: "no_answer",sub_status: "voicemail_detected"(lowercase — failure family). - Verdict landed late, after the call connected:
status: "completed",sub_status: "VOICEMAIL_DETECTED"(UPPERCASE — success family). The platform does not bill these calls.
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 raw status becomes a public status
| Internal value | 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 | You stopped the call or its batch. |
canceled | The call was cancelled before it dialled. |
call_disconnected | The call was disconnected by the platform. |
platform_error | An internal error terminated the call. |
Step 2 — carrier cause
Only reached if step 1 did not match. The decoding depends on which provider carried the call, and this is the single most misread part of the contract.
The provider code is the carrier's 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 provider code was recorded at all, resolution falls through to step 3.
Step 3 — the reason table
Reached when no provider 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
25 fixed tokens, plus the sip_<code> family:
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 directory resolves it, null on a miss or an outage. Do not key on it — agent_id is the identifier. |
| 2 | recording_duration and tool_call_logs are always null. Not sometimes — always. |
| 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.

