Skip to main content

Browser softphone: the client-side call lifecycle

The Web SDK’s softphone exposes one call at a time as a CallSession object. Every session moves through a seven-state client-side state machine, and the machine — not your UI guesses — is what should drive call screens: initial, ringing, progress, answered, on-hold, ended, failed. Read this page before you wire a softphone UI. The event vocabulary below is the browser’s half of the story; the server-side call record your backend observes has its own states and vocabulary, covered in Voice call lifecycle. The two advance in parallel and never map onto each other directly — section This page vs. the server-side lifecycle below settles which surface owns which question.

The client-side state machine

The kernel is a plain string union. One instance variable — nothing else — decides what your call screen renders: ended and failed are the two terminal states. Every other state can advance. One more property matters for UI work: an on-hold call is still an answered call. The SDK’s isAnswered() returns true for both answered and on-hold, so “is this call live enough to transfer or send DTMF” checks survive a hold. When you take the call off hold, the state re-enters answered — the FSM reports a resume as a transition back to answered, not a new state.

The statechange event contract

statechange is the single subscription that carries the whole machine:
Subscribe once, when the session is created — outbound sessions arrive from softphone.call(...) and inbound sessions from the softphone.on("incoming", ...) event. From that point on, every FSM advance re-fires statechange with the new state, and on-hold ↔ answered resume cycles emit real transitions: putting the call on hold emits on-hold, and resuming emits answered again. Your render loop should react to the latest state string, not accumulate a transition history. The granular events — ringing, answered, hold, mute, ended, media-level — still fire alongside, and they remain the right hooks for side effects (starting a VU meter on answered, logging the hangup cause on ended). But a screen that decides “which UI phase am I in” should branch on the state string from statechange, not on a hand-maintained set of event booleans.
ended always pairs with a cause string from the SIP layer (session.on("ended", (reason) => ...)). getState() gives you the where; the ended reason gives you the why. Use both — a cause-less statechange to ended cannot tell user hangup from rejection.

Incoming vs. outgoing entry points

session.direction is "incoming" | "outgoing", and it changes where the machine starts:
  • Outgoing — softphone.call(number) constructs the session at initial, then SIP moves it immediately to progress once the provisional response returns, then answered on 200 OK. You never see initial unless construction or INVITE transmission stalls.
  • Incoming — ringing inbound calls construct the session at ringing. answer() only applies to incoming sessions and throws on outgoing ones; hangup() before answer() rejects the call. Nothing after that differs — once answered, both directions walk the same answered / on-hold / ended arc.
Check direction once when you build the session; branch on it when you decide whether to paint an Answer/Reject popup or a Calling spinner. After answer, the two directions are indistinguishable to your UI.

Failure outcomes

failed collapses several distinct causes into one terminal state. Distinguish them on the ended/failed reason string the SDK passes through from the SIP layer, not on the state value:
  • User hangup — hangup() (or the remote party’s BYE) ends cleanly at ended. Expected; not a failure. Render a neutral “call ended” surface.
  • Reject / busy — the far end answered INVITE with a busy or decline response (486 Busy Here, 603 Decline). Terminal failed with a reason like “Rejected” / “Busy”. Render the reason; offer a redial affordance.
  • Network / transport failure — the WSS gateway unreachable, DTLS handshake failure, or a SIP timeout. Also terminal failed, surfaced with a cause like “Connection Error” or “WebSocket disconnected”. This and only this class merits an automatic redial loop — and only after you have ruled out the reject class, because a busy redial degrades your product and your caller experience.
Neither the state machine nor getState() carries the cause vocabulary. Keep a branch on ended’s reason string whenever you show failure UI.

This page vs. Voice call lifecycle

These are deliberate, separate machines:
  • This page — the client-side softphone session state. It lives in the browser SDK’s CallSession, advances on SIP messages, and exists to drive a call UI (what to render now).
  • Voice call lifecycle — the server-side call FSM on the call record (initiated → ringing → answered → completed, plus failed and the failure-status vocabulary). It lives on the platform, advances on network events, and exists so integrations can reconcile call outcomes from webhooks.
They fire in parallel and share no vocabulary: progress here has no server-side counterpart, server-side completed here surfaces as ended, and on-hold exists only on this client machine. Treat the client state as “what the user sees” and the server lifecycle as “what the platform recorded” — never branch one on the other’s terms.

Registration, retry, and “reconnecting” UI

The FSM above assumes the softphone is registered — the SIP user agent holds a WSS connection and a valid registration with the Orbit gateway. When registration drops, the SDK retries it with exponential backoff until it succeeds or exhausts its attempts, and every failure surfaces through the registration-failed event with the retry metadata your UI needs:
Tuning lives in one optional object, registrationRetry:
The delay before retry n is min(capMs, baseMs * 2 ** (n - 1)) plus a small jitter — defaults double from 1s capped at 60s, giving roughly two minutes of wall clock across eight attempts before the legacy error event finally fires. Each retry re-REGISTERs the existing credential; the credential itself refreshes only on the existing timed path (~5 minutes before expiry), so retrying does not thrash the token exchange. Tune the three knobs against your product’s tolerance: a softphone embedded in an agent desktop should probably retry longer (raise maxAttempts) before you paint it offline; a phone-gadget promo page may want to fail fast and clear (lower it). registration-failed fires on every attempt, willRetry=false marks exhaustion, and nextDelayMs is exactly what you should render in a countdown — those three fields exist so a “reconnecting” indicator never guesses.

See also

  • Voice call lifecycle — the server-side call FSM this page cross-references.
  • Web SDK — the softphone’s full SDK surface (place, answer, hold, DTMF, transfers, recording entry points).
  • SIP credential lifecycle — where the short-lived SIP credentials come from and how the SDK rotates them.