Post-call results

Verify signed lifecycle and artifact events, then optionally read a bounded advisory summary from a completed call.

Use signed lifecycle events to reconcile what Skysay did, and use the call evidence endpoints when you need the recording, transcript, or artifact itself. An optional post-call extraction is a convenience summary derived after a call; it is never the authoritative billing, policy, or customer-system record.

Configure an event endpoint

In the Workspace, configure the agent/webhook endpoint and use Send test event before relying on it. The test travels through the real delivery pipeline and appears in the delivery feed.

The REST routes are POST /v1/agent-endpoints, GET /v1/agent-endpoints, PATCH /v1/agent-endpoints/{agent_endpoint_id}, and GET /v1/webhook-deliveries; lifecycle controls are POST /v1/agent-endpoints/{agent_endpoint_id}/pause, /resume, and /test-event. The endpoint creation schema includes your webhook_url and audio_stream_url; save the returned webhook_secret immediately. It is revealed once and redacted on later reads. An agent created with POST /v1/agents gets its own endpoint, and that response reveals its secret the same way, once, in agent_endpoint.webhook_secret. See the generated API reference for the exact request schema and scopes.

events is set once at creation. Omit it (or send an empty array) to get the default subscription:

["call.created", "call.answered", "media.started", "call.ended", "call.completed", "call.failed", "call.canceled"]

Change it later with PATCH /v1/agent-endpoints/{agent_endpoint_id} — send the complete replacement list; an empty array is rejected there, since it would otherwise mean "subscribe to everything." The catalog also includes these useful call/result signals you can add to the subscription:

EventUse it for
call.created, call.queued, call.dialing, call.answered, media.startedLive lifecycle tracking
call.ended, call.failed, call.completed, call.canceledTerminal reconciliation
call.transcript.ready, call.recording.readyFetching newly available evidence
call.extraction.ready, call.extraction.failedReading optional advisory extraction status

call.canceled is terminal like call.failed: the call was cancelled before it reached the carrier, so no recording or transcript will follow and any reserved balance is released. Treat it as a stop condition wherever you wait for call.completed.

GET /v1/webhook-deliveries is the scoped delivery feed: it shows each event, endpoint, retry status, and safe failure evidence. It is the operational view when an endpoint needs attention.

Verify every delivery

Skysay sends a canonical JSON envelope and the same HMAC headers described for Integration Actions:

{
  "id": "evt_123",
  "type": "call.completed",
  "created_at": "2026-08-13T12:00:00Z",
  "organization_id": "org_123",
  "data": {
    "call_id": "call_123",
    "external_reference": "case_456"
  }
}

Verify X-Agent-Telco-Timestamp and X-Agent-Telco-Signature over the exact raw body before parsing it. Reject stale or invalid signatures, persist the event id, and make your event handler idempotent. Deliveries are retried with backoff, so the same event can arrive more than once. A successful response from your endpoint is an acknowledgement, not permission to skip deduplication.

Use terminal call events and the evidence endpoints as the Skysay outcome record. The data body contains result context; fetch the scoped resource when you need the full artifact instead of relying on event bodies as a data export. GET /v1/calls/{call_id} (using data.call_id from the event) is the authoritative fetch for the call itself after a terminal event — status, provider call ID, your own external_reference, and lifecycle timestamps.

Every call.failed result and call row returned by GET /v1/call-results includes a bounded technical_failure object. Evidence is available only for calls created after this contract was introduced; historical and unclassified failures return the same object with unknown availability rather than being reinterpreted:

{
  "evidence_version": 1,
  "availability": "available",
  "terminal_status": "no_answer",
  "failure_reason_code": "outbound_ringing_timeout",
  "failure_stage": "outbound_sip_participant",
  "sip_status_code": 487
}

Treat this as structured technical evidence, not a business outcome. The fields come from a fixed Skysay vocabulary; raw provider error text, SIP headers, exceptions, phone numbers, transcripts, credentials, and stack traces are not included. sip_status_code is nullable. When Skysay cannot prove a safe cause, availability and terminal_status remain unknown rather than being inferred from call duration or today’s configuration.

A call refused before anything dialled reports terminal_status: "failed" and failure_stage: "outbound_authorization", with failure_reason_code outbound_authorization_rejected (refused by your workspace capabilities) or outbound_authorization_held_for_review (held for review; its call status stays needs_review).

Only final SIP responses from 200 through 699 are retained in this terminal failure field. Provisional call-progress responses such as 180 Ringing are not terminal evidence and are omitted.

no_answer is narrower still: it is published only when a versioned durable outbound_ringing_timeout event proves it. A bare SIP 487 or elapsed duration does not prove no answer. In the example above, the ringing-timeout reason—not the accompanying 487—is the authoritative evidence. This contract applies prospectively; older failures are not backfilled or reclassified. The exact allowlisted values and pair constraints are defined by the OpenAPI schema.

worker_entry_failed (stage worker_entry) is the one reason Skysay's own media worker publishes. It means the platform ended the call itself: the call was admitted, and then the worker failed before it reported that media had started, so the person who answered would otherwise have been left listening to silence. Skysay hangs up that phone leg, closes the room, records this one call.failed result and releases the wallet reservation; nothing is charged for the call. There is no setting that changes this, and no override: a call that never delivered media is never continued. sip_status_code is null on this reason, because the phone leg itself did not fail. Retry policy treats it as a technical failure on Skysay's side, not as a busy or unanswered contact.

platform_maintenance (stage inbound_admission) means an incoming call arrived during a scheduled Skysay maintenance window. Skysay does not answer it, records the call as failed once, and charges nothing. It is not caused by your workspace's settings; see Maintenance windows.

inbound_capacity_reached (stage inbound_admission) means an incoming call arrived while every agent line on the platform's phone host was already in a call. Skysay does not answer that call: the caller hears a busy signal straight away, instead of ringing with nobody to pick up, and the call is recorded failed, once, with nothing charged. sip_status_code is null on this reason. A number's rule fallback is not applied to these calls yet, so the call is not forwarded anywhere.

An outgoing call never gets a busy for this: when every agent line is in a call as an outgoing call is about to be dialled, Skysay does not dial it. The call goes back to its queue and waits for a free line, and waiting spends none of its attempts. It is dialled once a line frees, and every check that applies to a call is made again at that dial: your do-not-call register, the campaign's state and the contact's calling window. If no line frees for about ten minutes, the call ends outbound_capacity_unavailable (stage outbound_worker): it was never dialled, nothing is charged, and sip_status_code is null.

When the configuration could not be run

Five reasons share the stage component_configuration. They mean the call was admitted and then refused before any audio existed, because the agent's resolved configuration could not build a conversation. Four of them name the missing piece:

ValueMeaning
voice_id_missingno voice is chosen, so the agent has nothing to speak with
tts_model_missingno speech model is chosen, so replies cannot become audio
llm_model_missingno language model is chosen, so the agent has nothing to think with
system_prompt_missingno instructions are written, so the agent does not know what the call is for

The fifth, component_configuration_rejected, is the general case: the configuration was refused for a reason outside that list — an unavailable provider or model for the call's language, a voice that is not in the approved inventory, or a language the agent is not published to answer in.

What the four name is the field, never its contents: system_prompt_missing tells you instructions are missing and never quotes them. As with every pre-media refusal, Skysay hangs up the phone leg, closes the room, records one call.failed result and releases the wallet reservation, so nothing is charged. sip_status_code is null: the phone leg itself did not fail.

These are configuration problems, not faults, and retrying the same call without changing the agent produces the same result. The agent's page in your workspace reports the same missing pieces before you place a call, and a browser test against an agent in this state is refused rather than connected. Calls that failed this way before this release carry no reason at all; they are not backfilled.

Read evidence and optional extraction

After the relevant ready event, use the generated reference routes for:

  • GET /v1/calls/{call_id}/recording
  • the recording metadata's download_url for authenticated full or byte-range audio
  • GET /v1/calls/{call_id}/transcript
  • GET /v1/calls/{call_id}/waveform
  • GET /v1/calls/{call_id}/artifacts
  • GET /v1/calls/{call_id}/post-call-extraction

Each read remains scoped to your organization and API key. Store only the evidence your policy and retention rules permit.

In the workspace, the fields a post-call extraction pulls out are set on each AI agent, in its After the call section, beside the free AI call summary; see After the call.

When a recording arrives late, or not at all

Skysay stores the recording, the transcript and the call's evidence for calls of any length up to the maximum call length. It stores them after the call ends, normally within moments of the hangup, and sends call.recording.ready and call.transcript.ready once they are available.

Storing can fail for a reason that a later attempt can outlive, such as a network fault or a platform update during the hangup. In that case Skysay retries on its own, for about five hours. The gaps between attempts grow: 1, 5, 15, 60 and then 240 minutes. The ready events are sent when a retry succeeds, so they can arrive hours after call.completed. Each is still delivered once. Until then, the call has no recording or transcript to read.

If storing is refused for a reason a retry cannot change, or the retries run out, the call's own event log (GET /v1/calls/{call_id}/events) records a call.artifacts.failed row with a reason and the stage that failed. No webhook is sent for it. A retry never changes what the call costs: the call is billed from its lifecycle when it ends, not from the stored recording.

Transcripts of GPT-Live calls

GPT-Live 1 listens and speaks at the same time, so the agent often starts talking before the caller has finished. Its transcript records what each side said, not a strict question-and-answer sequence:

  • Every line the agent spoke is a turn, including the opening and any line spoken while the caller was still talking. An agent turn is placed where its audio started, so it can begin before the caller turn it overlaps has ended. Its metadata.text_status is normally model_output_transcript: the text is the engine's own transcript of what it said.
  • A stretch of agent audio that the engine did not transcribe has no turn. It is still in the recording.
  • If the engine transcribed a line but its audio never played, the turn's metadata.actually_spoken.audio_observed is false.
  • If the call ends while the caller is still speaking, their last words are kept as a turn with metadata.text_status set to finalized_at_close. The engine had not finished transcribing that utterance, so the text can stop mid-sentence.

A call where only the opening played has a transcript with that one agent turn, as a Standard call does. It sends call.transcript.ready, and post-call extraction runs on it if the agent has an extraction schema.

End a call that is still running

POST /v1/calls/{call_id}/cancel withdraws a call that is still queued, before anything dials. Once a call is live that is no longer the right tool, and it fails closed rather than implying the carrier call was stopped.

POST /v1/calls/{call_id}/hangup is for a call that is already live. It answers 202 with a hangup_requested_at timestamp and deliberately does not report the call as ended: the media session is torn down by the worker holding it, and the call then reaches call.completed through the ordinary path. Ask twice and you get the original request back rather than a second teardown.

Until the teardown is acknowledged the request stays visible with status pending, so a request that nothing acted on is something you can see rather than silence. If a teardown fails, the call is still up, so asking again starts a fresh attempt; after three consecutive failures the endpoint refuses so the call is escalated rather than retried.

A call still queued for origination is refused with a pointer to cancel, and a call that has already finished is refused too.

Hang up from the workspace

The Hang up button on a live call's detail in the Calls page sends this same request after a confirm step; see End a live call from Calls.

Why a call ended

A completed call carries ended_reason on the call resource and in its terminal webhook:

ValueMeaning
remote_party_disconnectedthe other party hung up
hangup_requestedsomeone ended the call from your workspace or through the hangup endpoint
max_call_duration_reachedthe call reached its configured maximum length
funding_exhaustedthe current credit reservation could not be extended before its funded deadline — see Funding a running call
no_caller_speech_after_answerthe call was answered but nobody ever spoke
agent_completedthe agent finished the conversation and ended the call
media_session_lostthe media session went away while the call was still live
no_human_after_answerthe call was answered but only music or tones were ever heard
model_session_lostthe conversation engine behind the call failed and could not be recovered
agent_unresponsivethe other party spoke and the agent produced nothing for the whole of its bound — see When the agent stops answering — or, with an empty reason_detail, the agent's opening could not be produced and never reached the caller (details)
caller_unresponsiveboth sides fell silent after the agent spoke, the agent asked whether the caller was still there, and nobody answered — see How a call ends
ivr_timeoutan outbound call that was in a phone menu or a hold queue ended there: a limit ran out, the menu had no way to a person, or it asked for a value the call did not carry; reason_detail says which. Phone menu navigation is on by default, so any call your AI agent places can end this way
listener_unavailablea workflow agent's speech listener (its speech-to-text provider) failed while the caller was on the line, so the agent could no longer hear them — see When a workflow's listener fails
workflow_line_undelivereda workflow agent could not deliver one of its lines before the end of the workflow, so the workflow could not go on — see When a workflow line is not delivered

The workspace Calls detail explains funding-related endings. Read the full ending-reason vocabulary from the call resource or the call.completed webhook.

That is the whole set. The field is empty on a call that has not completed, and on calls that completed before this shipped. A call that failed reports its reason in technical_failure.failure_reason_code instead; the two vocabularies are separate and do not overlap.

A call also carries answer_classification and reason_detail. answer_classification says what was heard after the call was answered rather than why it ended. reason_detail is the qualifier, and it qualifies one of the two, which you can tell from its value:

reason_detailqualifiesmeaning
caller_tune, hold_musicanswer_classification non_human_audiocontinuous music or tones held the line
looping_recordinganswer_classification non_human_audioa recording repeated the same cycle of things over and over
no_audioanswer_classification silentthe line answered and nothing at all was heard
model_unresponsiveended_reason agent_unresponsivethe agent stopped answering: see below
insufficient_balanceended_reason funding_exhaustedthe latest renewal confirmed insufficient eligible credit
renewal_unavailableended_reason funding_exhaustedadditional funded time was not confirmed by the deadline
hold_limit, navigation_limit, deadline, menu_no_path, data_not_authorisedended_reason ivr_timeoutthe time allowed on hold, the time allowed in menus, or the call's overall maximum length ran out while the call was waiting; or the menu offered no way to a person; or it asked for a value the call did not supply. See How these calls end
during_holdended_reason remote_party_disconnectedthe other party hung up while the call was waiting in a menu or on hold
recorded_message_onlyended_reason no_human_after_answera recording and nothing else answered: no menu, no person
navigation_detector_lostended_reason media_session_lostthe listening a waiting call depends on stopped delivering

It is empty when neither needs qualifying, which is most calls. no_human_after_answer is the ending that goes with the non_human_audio classification, and only an agent set to end_call ends a call that way. See Answer detection for the whole set, for the two situations that end a call, and for the per-agent setting.

A wait that ran out after a conversation

The call resource also carries person_reached: true when a person was reached earlier in the call, false when nobody was, and null when the call did not report it (a call that completed before the field shipped, an agent that does not handle phone menus, and any ending the platform did not decide). It matters for the three endings that mean "the call waited and nobody came": ivr_timeout with hold_limit or deadline, and remote_party_disconnected with during_hold.

person_reachedcampaign dispositionpick-up and talk time
falseno_answer: the contact is dialled againthe call is not a pick-up and its recording is not talk time
trueconnected_completed: terminal, the contact is not dialled againthe call is a pick-up and its recording is talk time
nullconnected_completed: missing information never starts a redialexcluded, as it was before the field existed

A conversation followed by a transfer into a queue that ran out of time has reached its contact, so it is neither redialled nor discounted. ivr_timeout with navigation_limit, menu_no_path or data_not_authorised is terminal whatever person_reached says, and no_human_after_answer is never a pick-up. The same field is in the call.completed webhook, and the terminal call.ended event's details carries it as a boolean when the platform reported one.

When the media session is lost

media_session_lost is the one reason that names something nobody chose. It means the media session underneath a live call went away: the room was removed, or the connection carrying the audio failed. Nobody hung up, no limit was reached, and the agent did not finish.

It reports what was observed rather than guessing at a cause. In particular, if someone had just asked to end that call, the call is not reported as hangup_requested. The hangup request is answered separately, and its answer says the call was already gone. A reason that named a person who did not end the call would be worse than the empty reason this replaces.

Calls that end this way used to carry no reason at all, so if you are counting reasons over time you will see empty values become media_session_lost from this release rather than a new kind of ending appearing.

Test calls run from the Simulations Lab can also report it. A synthetic room sometimes closes before the synthetic caller's own departure is delivered, which is the same observation from the platform's point of view. A Lab result is judged on its assertions, not on this field.

When the conversation engine is lost

model_session_lost is the engine-side counterpart of media_session_lost. The media session was fine and the other party was still on the line, but the conversation engine behind the call — the realtime model's connection to its provider, or a speech component that ran out of retries — failed and could not be brought back. Nobody hung up, no limit was reached and the agent did not finish. Skysay ends the call at that moment rather than leave the line open, with nobody on it, against your balance.

Before this release such a call stayed open until its maximum length or until the fault check below settled it, and carried no reason. As with media_session_lost, a hangup request that was in flight is answered separately, and the call is not reported as hangup_requested.

When a workflow's listener fails

An agent that runs a workflow hears the caller through its speech listener, the speech-to-text provider the workflow is set to use.

If the listener's connection fails (it never connects, or its stream closes or fails), Skysay reconnects it once per call. The new connection is sent everything the caller said in their current turn, from its first word, so nothing is lost, and the call carries on as normal. The reconnect has about 3 seconds after the caller stops speaking to deliver their words.

Skysay ends the call with ended_reason listener_unavailable when the agent can no longer hear the caller:

  • the reconnect also fails, or does not deliver the caller's words in time;
  • the listener fails a second time on the same call (only one reconnect is made per call);
  • the listener fails for a reason a new connection cannot fix, such as a rejected configuration or a stream that reached its time limit;
  • the caller's current turn is more than about 26 seconds long when the connection fails, because only the last 26 seconds of audio can be resent.

Audio resent on a reconnect can be counted again in the call's speech-to-text usage: at most the part of one turn the failed connection had already received, at most 26 seconds, once per call. If the first connection never opened, nothing was sent to it and nothing is counted twice.

When a call ends this way, nobody hung up, no limit was reached and the workflow did not finish. Such a call used to carry no reason, and its workflow read as if the caller had hung up. The call's workflow.status is still incomplete, because the workflow stopped before it finished; it did not fail on its own.

Anything the caller said that the listener did not deliver is not in the transcript, and that includes a request to stop calling. Before you dial a contact again after this ending, check the call's recording.

When a workflow line is not delivered

A spoken workflow only moves on from a step once the caller has heard its line. Skysay ends the call with ended_reason workflow_line_undelivered when a line before the end of the workflow could not be delivered:

  • On GPT-Live 1, the line was played but GPT-Live did not say it in full (see how GPT-Live speaks a workflow). The workflow run's fail_reason is LINE_NOT_SPOKEN.
  • On any engine, the caller spoke over the same step three times while its line had still not been heard in full. Words said over a line that was not heard are never taken as an answer to it, so the step is spoken again, at most twice; the third time the workflow stops. Only a line that had begun playing counts: words said before a repeat started playing do not. The run's fail_reason is UNHEARD_REPEATS_EXHAUSTED. A stop request, a wrong-person reply and your workflow's other global interrupts are still recognised on every one of those turns, exactly as before.

Nobody hung up and no limit you set was reached. The call's workflow.status is failed: the workflow could not go on. Such a call used to carry no reason, and its workflow read as if the caller had hung up. Check the recording for what the caller heard and said before you contact them again.

When the agent stops answering

model_session_lost is reported only when the engine tells Skysay it has failed. An engine can also simply stop answering without reporting anything — a provider outage in the middle of a call can leave the connection open and silent — and a call in that state used to stay up, with the caller talking to nobody, until it reached its maximum length. It was billed for all of it.

Skysay bounds that. After the other party finishes speaking, the agent has a fixed amount of time to produce something: a reply, a tool call, or the end of the call. A reply counts even if the agent held it back rather than talk over the other party and it was never played because they kept talking, so a caller who talks in short bursts is never ended this way. If the whole bound passes with nothing at all, Skysay ends the call with ended_reason agent_unresponsive and reason_detail model_unresponsive. It says what was observed — an agent that went quiet with its engine still connected — and not that anything failed, because some engines report nothing when they stop answering.

Calls that ended this way before 2026-09-25 reported ended_reason model_session_lost with the same reason_detail model_unresponsive. If you count endings over time, treat that pair as agent_unresponsive.

The bound is 20 seconds by default, and it counts only time the line was free. It starts when the other party stops speaking, and their own speech is subtracted from it: someone who keeps talking is never cut off mid-sentence, while someone who pauses between attempts to get an answer still reaches the bound. It is cleared by anything the agent does that the other party can tell is happening -- a reply they can hear, a tool call -- so a normal conversation never approaches it, and it never runs while one of the agent's tools is still running, before the other party has said anything at all, or on a call whose replies come from a workflow. "The agent is speaking" means audio actually reaching the line, not an engine that says it is speaking: an engine that has stopped sending anything is exactly what this bound is for. A call where nobody speaks at all is a different situation with its own reason, no_caller_speech_after_answer.

You can change the bound per agent with voice_polish.model_unresponsive.seconds on PATCH /v1/agents/{agent_id}/voice-stack: any value from 10 to 120 seconds, or 0 to switch it off entirely for an agent that waits on something genuinely slow. Leaving it unset keeps the 20-second default.

{ "voice_polish": { "model_unresponsive": { "seconds": 45 } } }

When a call ends automatically

Most calls end because someone hung up, or because the agent finished what it had to say. Both are reported now. A call the other party hangs up on carries remote_party_disconnected, and one the agent ends itself — its script reaches a goodbye, or its workflow completes — carries agent_completed. Both were empty before this release, so from here most completed calls carry a reason rather than none. In a scripted workflow agent_completed will usually be the commonest of all.

How the agent ends a call — the end_call tool, where its goodbye comes from on each conversation engine, and the standing instruction every agent with end_call receives — is described in How a call ends.

When the agent ends the call itself, its closing line is now played to the end before the call is torn down. Skysay learns that a line has finished playing slightly before the other party has actually heard it, and hanging up at that moment clipped the last word or two. The call is instead held open until the audio going to the caller has fallen quiet. That wait is short and bounded: it gives up after about a second and ends the call anyway, so a closing line that never finishes cannot hold a call open. Where the audio could not be observed, the ending is recorded as unconfirmed rather than as heard.

Beyond those two, Skysay ends a live call by itself when one of the following applies, so a call cannot run unattended against your balance. These are safety limits rather than ordinary endings: they exist for a call that is going nowhere. Each reports the matching ended_reason above. The maximum length and the silence window can be set per agent, as described under Changing the values below; the empty-line check uses a platform value that is the same for every agent.

Nobody is left on the call. The call ends once the agent is the only one on it. Normally the network reports the moment the other party leaves. As a backstop Skysay also asks the media server who is on the call every 15 seconds and ends one that has been empty for 15 seconds continuously, so a notification that never arrives cannot leave an agent talking to nobody. Both routes report remote_party_disconnected, because they are the same ending; by the backstop the call ends within about 30 seconds of the line going empty.

The call reached its maximum length. A call ends after 29 minutes by default and reports max_call_duration_reached. The limit sits just under the 30 minute ceiling of the underlying media session, so the call ends with a stated reason rather than being cut off without one.

Nobody spoke after the call was answered. If no speech is heard from the other party for 20 seconds after the call is answered, by default, the call ends and reports no_caller_speech_after_answer. A call stays open while the caller is heard, by voice, by transcript, or by the engine's own detection: speech is detected from the audio itself by voice activity detection; on a GPT-Live 1 or Gemini Live agent the engine's own live captions of the other party count as well, since it hears the caller directly; and on a Gemini Live agent the engine starting a reply of its own to what it heard counts too (its automatic reply to a tool result does not). Once the other party has been heard once the rule does not apply again for the rest of that call. This measures audio, not intent: a voicemail greeting is speech, so this rule is not a voicemail detector. What it catches is a call that connects to silence.

Both sides went quiet mid-call (rolling out: observed but not yet enforced; see the link below). Once the agent has spoken and nothing more comes from either side for 30 seconds, the agent asks once whether the caller is still there. If nobody answers within 15 seconds after that question, the agent says a short goodbye and the call ends with caller_unresponsive. A caller who answers, even with one word, simply continues. The details, including when the clock does not run, are in How a call ends.

If the software handling your call faults

A call is handled by a worker process, and that worker normally reports the ending itself. If it stops — it crashes, or it stops making progress while the call is still open — Skysay settles the call for you rather than leaving it open against your balance. Two checks do this, and neither is configurable:

  • The worker stops reporting. Its status is checked every 5 seconds, and a worker silent for more than 15 seconds is treated as gone. The call is settled within roughly 20 seconds.
  • The worker keeps reporting but the call is empty. Skysay asks the media server who is on the call. If the other party has been absent for 2 minutes continuously while the worker is still reporting, the call is settled anyway. The wait is deliberately longer than a healthy ending takes, so a merely slow worker is never overtaken and still gets to report its own reason.

A call settled this way is completed, and it carries the ended_reason the worker had already decided — remote_party_disconnected when the other party left, and so on from the table above. No new value is introduced. When the worker faulted before deciding anything, ended_reason is empty rather than guessed at, because nothing observed why the call ended.

The billed length is measured to the moment the call actually ended, not to the moment the fault was noticed. If the worker had decided on an ending, that instant is used, whichever of the two checks settled the call. Where it decided nothing, each check falls back to the closest boundary it can actually observe: the worker's last report when the worker went silent, and the first check that saw the other party gone when the worker was still reporting. A call that hung for half an hour after the other party left is billed to when they left.

A hangup you request yourself is not automatic either, and it reports hangup_requested. Ask before the call has started ending and that is what you get; ask once the agent's own ending is already under way and the call keeps agent_completed, because by then it was finishing anyway. If the teardown fails the request is reported back to you as failed rather than retried on your behalf, and asking again starts a fresh attempt.

What you are billed

A call ended by one of these rules is billed exactly like any other completed call. Nothing extra is charged for the ending itself, and because the call stops sooner than it otherwise would, these rules can only reduce what a call costs.

The billed duration starts when the call is placed and ends when the call ends; ringing time before the answer is included.

A call's recorded end is the moment the call ended, not the moment Skysay finished tearing it down. Those are normally a second apart. Where they are not, because a teardown was slow, the gap between them is not billed to you and does not appear in the call's duration.

Changing the values

The maximum length and the silence window are per-agent settings. Set them on the agent's turn-taking configuration with PATCH /v1/agents/{agent_id}/voice-stack, or the equivalent MCP tool:

SettingDefaultRange
max_call_seconds1740, that is 29 minutes60 to 1740
no_caller_speech_seconds2010 to 120

A value outside its range is refused, not quietly adjusted, so a setting the API accepted is the setting your call runs. An agent that sets neither uses the defaults above, and existing agents are unaffected.

Each range has a reason worth knowing before you pick a value. max_call_seconds cannot exceed its default, so this setting can only make a call end sooner: the media session behind a call is dropped at 30 minutes, and a limit above the ceiling could never fire, which would leave the call ending with no reason attached. no_caller_speech_seconds has a floor because the platform listens for voice activity rather than for meaning, so somebody who is merely listening looks the same as somebody who is not there; a shorter window would end the call partway through the agent's own opening line.

The empty-line check is not settable. If its timing does not suit your use case, contact support rather than working around it in your agent.

Reconcile a bounded external call group

If your application supplied the same opaque external_group_reference when creating several calls, read that one group with GET /v1/call-results. This is a factual, read-only reconciliation view: it does not retry recipients, create calls, classify a business outcome, or contact a carrier.

GET /v1/call-results?project_id=proj_123&external_group_reference=import_2026_09_01&limit=50

The key is meaningful only to your application. It must be inside the authenticated organization and project, and Skysay does not give it campaign, collection, retry, or business-state semantics. The route requires both calls:read and recordings:read because its evidence pointers can lead to authenticated recording or transcript resources.

The response keeps authoritative technical call facts separate from optional advisory extraction. It contains only bounded, text-free evidence anchors (turn IDs and time ranges), not a transcript or audio copy. Open the authenticated per-call recording or transcript route when your own review policy needs the source material. If evidence.availability is unavailable, the response could not assemble that optional projection; it does not prove that an underlying recording or transcript is absent. Retry the authenticated per-call evidence route rather than treating it as a business outcome.

Each call row also contains the versioned technical_failure projection described above. Persist its evidence version and treat unknown as a valid answer. Do not reconstruct a cause from transcript text, duration, or provider messages in another field.

Use the returned pagination.next_cursor while pagination.has_more is true; a page is not a complete group. You can narrow a page by terminal status, artifact readiness, extraction state, or generic platform-quality exception. Those filters never apply your business taxonomy.

The group publishes answered_or_voicemail with its numerator and denominator because carrier/media answer evidence can include voicemail. It does not label that value a human-answer or connect rate. Until your application has its own validated signal, human_answered and voicemail are explicitly not_provable.

For optional extraction, schema_version identifies the immutable customer schema used for that call, while analysis_version identifies the Skysay-owned advisory evidence envelope. analysis_version is snapshotted when the extraction is enqueued: a later Skysay analysis release never relabels an existing call's result, although newly enqueued work can use a newer version. Preserve both when you store a reference to an extraction. Handle call.extraction.ready and call.extraction.failed idempotently, then reconcile against this read or the single-call endpoint before taking an application-owned action.

Review conversation languages

In Calls, select a call and open its language history above the transcript. For supported Follow-caller sessions, the imported call evidence records applied, rejected, and failed language changes, their source, and the published configuration version used by the call. A queued request is not proof that the AI agent changed language.

The history describes the AI agent's response-language configuration. It does not label every caller utterance or certify which language was spoken in the audio. Listen to the recording and read the transcript to check the actual conversation. An explicit-request source identifies the controlled switch-tool path; provider-evidence identifies the supported language-detection path.

Language history is post-call evidence, not a live monitoring feed. It becomes available after the worker's telemetry artifact has been imported. A partial history means the worker could not capture the complete sequence, including when its event limit was reached. Unavailable means no supported history has been imported; older calls are not reconstructed from today's agent settings. An available history with no changes is different from missing evidence.

Times are relative to the worker's call clock. They are not automatically recording seek positions: a recording can start on a different clock. The UI does not invent recording offsets or transcript language labels from these events. Language evidence does not replace the call's billing or terminal status records.

Optional post-call extraction

Configure a hosted agent with PATCH /v1/agents/{agent_id}/post-call-extraction. The request has enabled and a JSON schema. When enabled, the schema must be an object with 1–24 scalar properties (string, number, integer, or boolean), optional string enums, and no additional properties.

{
  "enabled": true,
  "schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "requested_callback": { "type": "boolean" },
      "follow_up_reason": { "type": "string" }
    }
  }
}

Each scalar property can restrict its evidence with "x-skysay-source": "caller". The default, "conversation", uses both speakers. Caller fields receive only captioned caller speech, so an agent's recap or an unspoken form value cannot supply the answer. A bare “yes” cannot establish a value mentioned only in an omitted agent question; that value remains unknown. Choose caller fields for contact preferences and facts the caller actually states. A caller-only summary is deliberately one-sided and may omit context from the agent's questions.

{
  "lender": { "type": "string", "x-skysay-source": "caller" },
  "call_outcome": { "type": "string", "x-skysay-source": "conversation" }
}

The annotation belongs on individual scalar properties, not the root schema or nested objects. Other source values are rejected. Skysay uses one bounded transcript read and at most two model requests per worker attempt, one per nonempty source group. Normal worker retries repeat these requests. With no captioned caller words, caller fields return null without a model request. If either model request fails, the extraction follows the normal retry/failure process; a partially successful result is never published. The result remains an AI estimate, not verified caller identity or consent.

Every configuration edit creates an immutable version for future calls. After a completed call has a transcript, a separate worker may produce a result. Read it through GET /v1/calls/{call_id}/post-call-extraction; its status is one of not_configured, pending, running, ready, or failed.

Advisory, not an action

Extraction never changes a call, policy decision, wallet, customer record, or integration. It can support review and triage, but your application must verify the source evidence before it performs a business action.

Campaign results carry the extraction too: the results export writes one extraction.<field> column per schema field beside the call's workflow outcome, and the review inbox shows the same fields inline for every call that asked for a person.

Security and reliability checklist

  • Keep the reveal-once webhook secret in your secret manager; rotate by creating a new endpoint if it is lost.
  • Verify raw-body HMACs and freshness before processing any event.
  • Deduplicate by event id and reconcile terminal status with call evidence.
  • Pause a failing endpoint, repair it, send a test event, then resume it.
  • Keep post-call extraction schemas short, neutral, and reviewable; do not use them as a substitute for a verified customer decision.