Graine AI

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".

{
  "event_type": "all_processing_completed",
  "event_id": "3a91c47f-8e2d-5b06-9f14-7c25d8a0e6b3",
  "version": "1.0",
  "timestamp": "2026-08-27T09:41:22.518Z",
  "call_id": "8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88",
  "call_sid": "b2f4d9e0-71a3-4c58-8e2b-3d6f105ac947",
  "agent_id": "agent_5b7d21e9c8a34f60",
  "agent_name": "Renewals outreach",
  "organization_id": "organization-live-4821",
  "workspace_id": "organization-live-4821",
  "status": "completed",
  "sub_status": "CUSTOMER_ENDED_CALL",
  "call_type": "outbound",
  "calling_source": "custom",
  "custom_args_values": {
    "policy_number": "PN-88431",
    "renewal_date": "2026-09-14",
    "tier": "gold"
  },
  "to_number": "+14155550123",
  "from_number": "+14155550100",
  "bulk_list_id": "batch_01HZY3Q4K7M8N9P0R1S2T3U4V5",
  "called_on": "2026-08-27T09:38:04.221Z",
  "created_at": "2026-08-27T09:38:03.907Z"
}
FieldTypeDescriptionWhen null
event_typestringOne of the eight event names.Never
event_idstringStable 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
versionstringPayload schema version. Currently the constant "1.0" for every event.Never
timestampstringWhen this payload was built. ISO-8601 UTC, always exactly three fractional digits, always Z: YYYY-MM-DDTHH:MM:SS.mmmZ.Never
call_idstringThe 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_sidstring | nullThe telephony provider's own call identifier.No provider id was recorded (a call that failed before the carrier accepted it).
agent_idstring | nullThe 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_namestring | nullHuman-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_idstring | nullYour organization.The record carries no organization id.
workspace_idstring | nullAlways 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.
statusstringThe public call status. One of nine values — see Status.Never. An unrecognised raw status becomes "failed", never a raw string.
sub_statusstring | nullThe reason behind the status — see Sub-status.Always null when status is queued, initiated, ringing or in_progress.
call_typestring | nullDirection, typically "outbound" or "inbound".Not recorded.
calling_sourcestring | nullAlways "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_valuesobjectYour own call variables echoed back — see custom_args_values.Never null. {} when you sent none.
to_numberstring | nullThe 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_numberstring | nullThe caller ID presented. Same decryption guarantee.Same as to_number.
bulk_list_idstring | nullThe batch this call belonged to.A direct POST /v2/calls that was not part of a batch.
called_onstring | nullWhen this attempt was dialled, from the contact's attempt history.No timestamp available. See the caveat below.
created_atstring | nullWhen 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:

ab_is_variant        ab_resolved_agent_id   ab_version_id        agent_id
agent_name           batch_id               call_category        call_sid
call_type            callee_name            campaign_id          direction
execution_id         follow_up_count        follow_up_trigger    from_number
hangup_detail        is_follow_up           member_id            mobile_number
org_id               organization_id        origin               parent_execution_id
phone_number         provider               recipient_data       request_id
retry_count          stream_sid             telephony_data       test_type
to_number            user_data              user_data_pakcet     version
webcall_agent_id     webhook_url

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.

{
  "event_type": "all_processing_completed",
  "event_id": "3a91c47f-8e2d-5b06-9f14-7c25d8a0e6b3",
  "version": "1.0",
  "timestamp": "2026-08-27T09:41:22.518Z",
  "call_id": "8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88",
  "call_sid": "b2f4d9e0-71a3-4c58-8e2b-3d6f105ac947",
  "agent_id": "agent_5b7d21e9c8a34f60",
  "agent_name": "Renewals outreach",
  "organization_id": "organization-live-4821",
  "workspace_id": "organization-live-4821",
  "status": "completed",
  "sub_status": "CUSTOMER_ENDED_CALL",
  "call_type": "outbound",
  "calling_source": "custom",
  "custom_args_values": {
    "policy_number": "PN-88431",
    "renewal_date": "2026-09-14",
    "tier": "gold"
  },
  "to_number": "+14155550123",
  "from_number": "+14155550100",
  "bulk_list_id": "batch_01HZY3Q4K7M8N9P0R1S2T3U4V5",
  "called_on": "2026-08-27T09:38:04.221Z",
  "created_at": "2026-08-27T09:38:03.907Z",
 
  "call_cost": 0.0412,
  "call_cost_currency": "USD",
  "cost_breakdown": {
    "llm": 0.0061,
    "network": 0.0198,
    "platform": 0.005,
    "synthesizer": 0.0074,
    "transcriber": 0.0029,
    "total": 0.0412
  },
  "call_duration": 118.4,
  "agent_message_count": 7,
  "user_message_count": 6,
  "overall_latency_seconds": 0.843,
  "first_utterance_seconds": 1.216,
  "transcript": [
    { "role": "assistant", "content": "Hi, this is Ava calling from Northwind Insurance. Am I speaking with Priya?" },
    { "role": "user", "content": "Yes, this is Priya." },
    { "role": "assistant", "content": "Great. Your policy PN-88431 comes up for renewal on the 14th of September. Would you like me to walk you through what changes?" },
    { "role": "user", "content": "Sure, but can you make it quick? I'm about to head into a meeting." },
    { "role": "assistant", "content": "Of course. Your premium stays flat at 240 dollars a year, and roadside assistance is now included at no extra cost." },
    { "role": "user", "content": "That's fine. Can you send me the details by email?" },
    { "role": "assistant", "content": "Absolutely, I'll send that to the address on file today. Anything else before you go?" },
    { "role": "user", "content": "No, that's all. Thanks." },
    { "role": "assistant", "content": "Thank you, Priya. Have a good meeting." },
    { "role": "user", "content": "Bye." }
  ],
  "transcript_status": "available",
  "recording_url": "https://recordings.graine.ai/organization-live-4821/8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88.wav",
  "recording_duration": null,
  "retry_count": 0,
  "attempts": [
    {
      "call_id": "c41b8ea6-9d27-4f13-b0a5-7e82c6d3419f",
      "status": "no-answer",
      "attempted_at": "2026-08-26T14:02:11.004Z",
      "retry_count": 0,
      "from_number": "+14155550100"
    },
    {
      "call_id": "8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88",
      "status": "completed",
      "attempted_at": "2026-08-27T09:38:04.221Z",
      "retry_count": 1,
      "from_number": "+14155550100"
    }
  ],
  "parent_call_id": "c41b8ea6-9d27-4f13-b0a5-7e82c6d3419f",
  "retry_chain": [
    "c41b8ea6-9d27-4f13-b0a5-7e82c6d3419f",
    "8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88"
  ],
  "platform_analysis": {
    "summary": "Priya confirmed her identity and agreed to the renewal terms for policy PN-88431. She asked for the details by email and ended the call early for a meeting.",
    "call_disconnect_reason": "user_hangup",
    "key_points": null,
    "action_items": null,
    "callback_requested": false,
    "callback_time": null,
    "sentiment": "positive",
    "sentiment_score": 0.72,
    "sentiment_analysis": "Cooperative throughout. Mild time pressure near the end, but no objection to the renewal terms.",
    "tags": ["renewal", "email-followup"],
    "timezone": "America/Los_Angeles"
  },
  "platform_analysis_status": "completed",
  "client_analysis": {
    "renewal_confirmed": true,
    "preferred_contact_channel": "email",
    "objection_raised": null
  },
  "client_analysis_status": "completed",
  "tool_call_logs": null,
  "answered_by_voice_mail": false,
  "hangup_by": "caller",
  "ab_version_id": null,
  "ab_is_variant": null,
  "processing_incomplete": false,
  "missing_fields": []
}

Fields beyond the envelope

FieldTypeDescriptionWhen null
call_costfloat | nullTotal 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_currencystringISO currency code for call_cost and cost_breakdown. Defaults to "USD".Never
cost_breakdownobject | nullPer-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_durationfloat | nullCall 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_countinteger | nullNumber 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_countinteger | nullNumber of user turns.Same rule as above.
overall_latency_secondsfloat | nullMean 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_secondsfloat | nullWhen 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.
transcriptarray | nullOrdered turns, each {"role": "assistant" | "user", "content": "..."}.No transcript. Always null, never [] — except in one truncation edge case noted below.
transcript_statusstring"available", "unavailable", or "truncated" (the body exceeded the size cap and turns were dropped from the end).Never
recording_urlstring | nullAudio recording of the call.No recording was produced or it has not been stored.
recording_durationnullAlways 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_countintegerWhich 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.
attemptsarray | nullEvery 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_idstring | nullThe call_id of the attempt this one retried.This is the first attempt.
retry_chainarray[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_analysisobjectAlways present, always 11 keys. See below.Never
platform_analysis_statusstring"completed" (summary and sentiment present), "partial" (one of them), "unavailable" (neither).Never
client_analysisobjectYour Custom Analysis variables, extracted from the transcript. Keys are the variable names you configured. Always an object, possibly {}.Never null.
client_analysis_statusstring"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_logsnullAlways 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_mailboolean | nullWhether an answering machine picked up.The field was never set on the record.
hangup_bystring | nullWho ended the call. Common values: "caller", "callee", "agent", "transfer".Not recorded.
ab_version_idstring | nullThe A/B experiment that routed this call.The call was not part of an experiment.
ab_is_variantboolean | nulltrue if the variant served this call, false if the control did.The field was never set.
processing_incompletebooleantrue when the barrier timed out and one or more producers never reported.Never null.
missing_fieldsarray[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

FieldTypeDescriptionWhen null
call_idstring | nullThe execution id of that attempt.Not recorded for that attempt.
statusstring | nullThe 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_atstring | nullISO-8601 UTC dial time for that attempt.Not recorded.
retry_countinteger | nullWhich retry that attempt was. 0 is preserved and means a genuine first attempt — it is not coerced to null here.Not recorded.
from_numberstring | nullCaller 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.

FieldTypeDescriptionWhen null
summarystring | nullNarrative summary of the conversation.No summary was generated, or it could not be decrypted — you will never receive ciphertext here.
call_disconnect_reasonstring | nullThe 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_pointsarray | nullBullet 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_itemsarray | nullNext steps from the call.Same permanent-null rule as key_points.
callback_requestedboolean | nulltrue when the caller asked to be called back.No follow-up analysis ran for this call.
callback_timestring | nullWhen 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.
sentimentstring | nullOverall sentiment label, e.g. "positive", "neutral", "negative".Sentiment analysis did not run.
sentiment_scorefloat | nullNumeric sentiment score.Sentiment analysis did not run.
sentiment_analysisstring | nullProse explanation of the sentiment verdict.Not generated.
tagsarray[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.
timezonestring | nullIANA 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.

{
  "event_type": "call_ringing",
  "event_id": "a1c3f5e7-2b48-5d90-8f16-4e7b2c9d0a35",
  "version": "1.0",
  "timestamp": "2026-08-27T09:38:04.101Z",
  "call_id": "8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88",
  "call_sid": "b2f4d9e0-71a3-4c58-8e2b-3d6f105ac947",
  "agent_id": "agent_5b7d21e9c8a34f60",
  "agent_name": "Renewals outreach",
  "organization_id": "organization-live-4821",
  "workspace_id": "organization-live-4821",
  "status": "ringing",
  "sub_status": null,
  "call_type": "outbound",
  "calling_source": "custom",
  "custom_args_values": {
    "policy_number": "PN-88431",
    "renewal_date": "2026-09-14",
    "tier": "gold"
  },
  "to_number": "+14155550123",
  "from_number": "+14155550100",
  "bulk_list_id": "batch_01HZY3Q4K7M8N9P0R1S2T3U4V5",
  "called_on": "2026-08-27T09:38:04.221Z",
  "created_at": "2026-08-27T09:38:03.907Z"
}

Status

status is our own closed vocabulary. A raw carrier or internal status string is never echoed into this field.

The nine values

statusMeaningTerminal
queuedAccepted and waiting to dial — including scheduled and rescheduled calls.No
initiatedHanded to the telephony provider.No
ringingThe destination is ringing.No
in_progressThe conversation is live.No
completedThe call connected and ran to an end. This includes voicemail, transfers and agent errors — see sub_status for what actually happened.Yes
busyThe destination was busy.Yes
no_answerNobody picked up.Yes
failedThe call did not complete.Yes
canceledThe 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 valuestatus
queuedqueued
scheduledqueued
rescheduledqueued
initiatedinitiated
ringingringing
in-progressin_progress
completedcompleted
busybusy
no-answerno_answer
failedfailed
errorfailed
call-disconnectedfailed
balance-lowfailed
canceledcanceled
stoppedcanceled

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:

statussub_status casingExample
queued, initiated, ringing, in_progressAlways nullnull
completedUPPERCASEVOICEMAIL_DETECTED
busy, no_answer, failed, canceledlowercasebusy_here
anything unmodelledlowercaseunknown

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.

Ordersub_statusWins when
1VOICEMAIL_DETECTEDThe 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.
2MAX_CALL_DURATIONA web call hit its maximum duration ceiling.
3INACTIVITY_TIMEOUTThe conversation was ended after a silence timeout.
4AGENT_ERRORThe transcriber, synthesizer or language model errored during the call.
5TRANSFERREDThe call was handed to a human — either the hangup was attributed to a transfer, or transfer data was recorded.
6AGENT_ENDED_CALLThe agent decided the conversation was finished and hung up.
7CUSTOMER_ENDED_CALLThe person on the other end hung up.
8ACCEPTEDNone 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_statusWins when
voicemail_detectedThe 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_balanceThe wallet could not fund the call.
stopped_by_userYou stopped the call or its batch.
canceledThe call was cancelled before it dialled.
call_disconnectedThe call was disconnected by the platform.
platform_errorAn 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 codesub_status
200299 (the whole class)normal_clearing
403forbidden
404not_found
408request_timeout
480temporarily_unavailable
486busy_here
487request_terminated
500server_error
502bad_gateway
503service_unavailable
600busy_everywhere
603declined
any other codesip_<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 reasonsub_status
call completed successfullynormal_clearing
call ended from the customer's sidecustomer_ended_call
user disconnected the callcustomer_ended_call
call completed by the assistantagent_ended_call
answering machine detectedvoicemail_detected
call transferred to human agenttransferred
no answer from calleeno_answer
callee was busybusy_here
call failedcall_failed
call failed or was cancelledcall_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:

statussub_status
busybusy_here
no_answerno_answer
failedcall_failed
canceledcanceled

Step 5 — last resort

unknown.

The complete lowercase vocabulary

25 fixed tokens, plus the sip_<code> family:

agent_ended_call         insufficient_balance     request_terminated
bad_gateway              leg_failure              server_error
busy_everywhere          no_answer                service_unavailable
busy_here                normal_clearing          sip_<code>
call_disconnected        not_found                stopped_by_user
call_failed              platform_error           temporarily_unavailable
canceled                 request_timeout          transferred
customer_ended_call      declined                 unknown
forbidden                voicemail_detected

And the complete uppercase vocabulary, 8 tokens:

ACCEPTED            AGENT_ENDED_CALL     CUSTOMER_ENDED_CALL   MAX_CALL_DURATION
AGENT_ERROR         INACTIVITY_TIMEOUT   TRANSFERRED           VOICEMAIL_DETECTED

Null semantics — the ten rules worth pinning up

These are the distinctions that cost integration time when guessed at.

Rule
1agent_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.
2recording_duration and tool_call_logs are always null. Not sometimes — always.
3platform_analysis.key_points and action_items are permanently null for campaign calls.
4null is not 0 for agent_message_count, user_message_count and call_cost. null means unknown; 0 means measured as zero.
5retry_count is the exception: never null, and 0 means "first attempt or unknown".
6attempts is null — not [] — for direct-API calls with no contact record.
7transcript is null, never []. retry_chain, tags, missing_fields and custom_args_values are always present containers and may be empty.
8workspace_id is a copy of organization_id, not a second identifier.
9agent_id is the original, pre-A/B-resolution id.
10platform_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.