Skip to main content

Troubleshooting: conference lifecycle failures

A conference moves through a fixed lifecycle: POST /api/v1/voice/conferences creates the room, each dial-out leg produces a join or a failure, legs leave one at a time, and the room closes once with a terminal end_reason. Every step emits a webhook event, so a conference that “did nothing” always broke at a specific step — and the events you did and did not receive tell you which one. This page maps each conference symptom to the step that failed. For a single bad leg with audio trouble after a successful join, see Troubleshooting: voice call quality; this page works the room-level state machine, not per-call audio.

What the conference lifecycle tells you

Every conference emits the same four event types, in this order:
  1. conference.created — the create call succeeded and the room exists. Carries the requested participant count, so you can compare “how many legs were asked for” against how many ever joined.
  2. conference.participant_joined — one leg answered and entered the room. Fires once per successful leg, after the far end answers the dial-out.
  3. conference.participant_left — one leg disconnected, with duration_seconds, a free-text hangup_reason, and the last sip_response_code for that leg.
  4. conference.ended — exactly once, always the last event for a conference. It carries three fields that close the case:
The healthy shape is exactly one created, N participant_joined, up to N participant_left, and exactly one ended. Anything that deviates from that shape is the symptom — find yours in the map below. The full payload for each event is in Webhook events.

Symptom map

The conference was created, but no participants ever joined

You receive conference.created, then nothing — no participant_joined, and eventually conference.ended with final_status: "failed" or a timeout end_reason. The room was created but every dial-out leg failed before it could join. Per-leg failures don’t produce a participant_joined event; they are recorded on the conference’s participant roster with an error code instead. Fetch the conference and read the legs:
Each participant carries a status (failed, busy, no_answer, …) and, on failure, an error code and SIP response code. Match the code against the cause-and-fix table below — a trunk problem is fixed on the SIP trunk page, a compliance block on the contact or the gate, a balance block by topping up. One subtle case: when every seed participant on the create request fails to dial, the create can complete as a 201 while the returned data.status is already failed with CONFERENCE_DIAL_ALL_FAILED. Check status on the create response before treating a 201 as a healthy room — an idempotent retry replays the same failed row as another 201.

A participant joined, then dropped seconds later

You receive participant_joined and an immediate participant_left for the same leg. The dial-out succeeded — the far end answered — but the leg was torn down right after. Read the participant_left payload:
  • hangup_reason is free text from the carrier (Normal Call Clearing, caller hangup, RTP timeout). Match on substrings, never the exact string. Normal Call Clearing within a second or two of join usually means the far end’s own equipment (an IVR, a spam screener, a PBX) answered and hung up.
  • sip_response_code: 200 with a near-zero duration_seconds is a clean answer-then-bye; the failure is on the destination side, not the trunk.
  • An absent or non-200 sip_response_code on a short leg points back at the carrier path — work it as a trunk issue on the SIP trunk page.

The conference sits in completed but the room never really ended — or a zombie room lingers

A room closes exactly once. If the active participant count reached zero but no conference.ended arrived, the auto-close did not run; if the room shows completed in the dashboard but your system still treats it as live, the ended event was missed on your side.
  • Room still live with zero participants: the auto-close rolls up the room after the last leg disconnects, stamping end_reason: all_legs_terminated. A room that stays non-terminal with an empty roster needs one thing from you: end it explicitly. POST /api/v1/voice/conferences/{id}/end (the moderator/operator end action) closes the room and fires conference.ended with end_reason: manual_end. Never wait for the max-duration timeout backstop to do this for you — it exists to close genuinely stuck rooms, not to order your workflow.
  • Your system missed the closure: subscribe your webhook endpoint to conference.ended (or *) and replay missed deliveries from the webhook deliveries page — see Troubleshooting: webhook deliveries. conference.ended is the only signal a room closed; polling the conference read endpoint for status is the fallback, not the primary path.

There is no end_reason on a closed conference

Older closed conferences — and rooms closed through a path that stamped only the room row — can carry no end_reason. Treat a missing end_reason as “reason not recorded,” not as a distinct failure: derive the story from the roster instead. If every participant is in a terminal state (disconnected, left, completed), the room closed because everyone left — the same conclusion all_legs_terminated would have carried. If every participant is in a failure state (failed, busy, no_answer, rejected), the room closed because every leg failed. Conferences created today always stamp an end_reason; only do this derivation for legacy rows.

Causes and fixes

Conference leg failures carry one of two kinds of codes. Conference-level codes (CONF_*, CONFERENCE_DIAL_ALL_FAILED) come from the create step or the room as a whole. Per-leg gate codes come from the pre-dial checks on an individual addParticipant dial and are stored on that participant as its failure reason. Distinguish trunk failover from bulk leg failure while you read the roster: one leg failing with a 5xx SIP code or a trunk code is a route problem on that leg; every leg failing with the same code points back at the trunk or account-level gate (balance, send block), not at the individual destinations. Fix the shared cause once, not one participant at a time. A leg that fails with no SIP code at all (NO_ANSWER-class) never reached SIP — it was refused by a pre-dial gate, so the SIP trunk page can’t help; the gate’s code is the whole story.

When an event stops firing

Conference events follow strict sequencing per conference id: created is always first, participant_joined only ever precedes the matching participant_left for that leg, and ended is always last and fires exactly once. If your integration depends on that order, hold these expectations:
  • Per-leg failure never emits participant_joined. A leg that is refused pre-dial or fails post-dial appears only on the conference roster — it has no join/left pair. “Leg count from webhooks” and “participant count requested” diverge by exactly the failed-leg count; reconcile through the read endpoint, not by counting events.
  • Failure events are roster data, not webhooks. There is no conference.participant_failed event type; a silent failure is read off the conference, not listened for.
  • ended always closes the series. Any event gap before ended (a join you expected, a left you didn’t see) is a delivery gap, not a lifecycle gap — the conference service emits in order, and delivery is the layer that retries. Work it on Troubleshooting: webhook deliveries and Troubleshooting: webhook event dedup if a delivery replay shows duplicates instead.
  • An ended with no preceding joined events is valid. It means zero legs ever connected — the create-time failure path above, not a lost event.
The room’s own state is the fallback when events can’t reconcile: the conference read endpoint always reflects the current participant statuses and the terminal status/end_reason once the room closes.

See also