Skip to main content
Every call - whether you start it or a caller rings in - ends up the same way: your agent and the caller are two participants in a LiveKit room, and Unpod bridges the audio between them while streaming transcripts to your AgentRunner. This page traces both directions, the states a call moves through, and how it can end.
Animated call lifecycle diagram showing a caller entering Unpod, speech converted to text, dispatch to AgentRunner and Session, dialog turns, reply text, and final transcript, metrics, and webhook storage.

The state machine

Visible statusInternal stateWhat is happening
pendingqueuedEnqueued; waiting for a concurrency slot (outbound only).
ringingdialing / ringingThe SIP leg is originating, or the far end is ringing.
activeactiveBoth legs connected; your agent is talking to the caller.
completedendedThe conversation finished after being active.
failednot_connected / canceledNever reached a live conversation, or canceled pre-connect.
Inbound calls skip pending and dialing - the caller is already on the line, so the call starts at ringing/active. Only outbound calls pass through the full sequence. You never drive these states yourself; you observe them via the call status, the call_end hook, and the final end_reason.

Outbound: what happens after calls.create()

calls.create() is asynchronous. It does not wait for the phone to ring - it puts your call on a queue and returns immediately with status: "pending". The actual dialing happens in the background, gated by your plan’s per-account concurrency limit.
1

You enqueue the call

calls.create(pipe_id, to_number, ...) records the call and returns 201 with status: "pending". Your request is never blocked on the network.
2

Unpod gates on concurrency

A background worker picks up the call. If your account is at its concurrent-call cap, the call is automatically rescheduled and retried shortly - no error, no dropped call.
3

Unpod dials the number

Unpod creates a room and originates the SIP call to to_number. The call row moves to ringing, and you get a session_id.
4

Your agent joins and talks

Your AgentRunner is connected to the same room. Audio flows; transcripts stream to your dialog logic. The call row moves to active, then completed on hangup.
Because create() returns pending, poll client.calls.get(call_id) to watch the status advance. Don’t assume the call is connected the moment create() returns.

Inbound: what happens when someone calls your number

Inbound is simpler - there is no queue, because there is nothing to rate-limit. The moment a caller dials a number attached to your Speech Pipe, the call is already live and Unpod connects your agent to it.
1

Caller dials your number

The carrier delivers the call over SIP. LiveKit answers, creates a room, and adds the caller as a participant.
2

Unpod resolves your pipe

Unpod matches the dialed number to your Speech Pipe and its voice profile.
3

Your agent joins the live room

Unpod connects your AgentRunner to the room the caller is already in. Audio and transcripts start immediately.

The audio + transcript path

Once both legs are in the room, every call works the same way. Unpod’s speech stack transcribes the caller and streams plain text to your AgentRunner; your dialog logic replies with text; Unpod synthesizes it back to speech. The animated lifecycle above is the same loop in motion: call audio stays on Unpod’s side, text crosses into your runner, and reply text comes back for TTS.
Your agent_id selects which dialog brain runs for the call - it is not how Unpod routes the phone number. Number routing is handled by the Speech Pipe the number is attached to.

Voicemail detection

Unpod automatically detects when an outbound call has reached a voicemail system so your agent does not waste a turn talking to a machine. There are two detection points:
1

Pre-connect (during ringing)

Many carriers route “forwarded to voicemail” to a SIP user-unavailable signal after a brief ring. When Unpod sees this pattern, it classifies the call as voicemail before it ever connects, ends the call, and reports end_reason: "VOICEMAIL_DETECTED_PRECONNECT".
2

Post-connect (just after answer)

Some voicemail systems answer, play a greeting, then go silent. If a call connects, the caller never speaks, and the leg ends within a short window, Unpod classifies it as voicemail and reports end_reason: "AGENT_HUNG_UP_VOICEMAIL_DETECTED".

End reasons

When a call finishes, Unpod records why. These are the reasons you are most likely to act on:
end_reasonMeaning
USER_HUNG_UP_IN_CALLThe caller hung up during the conversation.
USER_DID_NOT_PICK_UPOutbound call rang out with no answer.
USER_HUNG_UP_RINGINGThe caller rejected the call while ringing.
AGENT_HUNG_UP_SILENCE_DETECTEDThe agent ended the call after prolonged user silence.
AGENT_HUNG_UP_VOICEMAIL_DETECTEDVoicemail detected after connect; agent hung up.
VOICEMAIL_DETECTED_PRECONNECTVoicemail detected during ringing; call never connected.
MAX_DURATION_REACHEDThe hard per-call duration cap was hit.
IDLE_TIMEOUTThe call was idle too long and was reaped.
SIP_FAILED_WRONG_NUMBERThe number was invalid or unreachable.
SIP_FAILED_NUMBER_BLOCKEDThe carrier blocked the call.
This is not the full set - additional reasons exist for SIP/trunk configuration errors and handover edge cases. Treat unknown reasons as a generic failure and log them.

Reacting to outcomes in your agent

Observe telephony outcomes through hooks rather than polling:
@ctx.session.on("call_end")
async def on_end(final_state: str) -> None:
    reason = ctx.session.data.get("end_reason")
    if reason == "VOICEMAIL_DETECTED_PRECONNECT":
        await schedule_retry(ctx.user_number, after_minutes=120)
    elif reason in ("SIP_FAILED_WRONG_NUMBER", "SIP_FAILED_NUMBER_BLOCKED"):
        await mark_unreachable(ctx.user_number)