> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Browser softphone: the client-side call lifecycle

> The seven-state client-side call state machine the browser SDK emits on every CallSession — initial, ringing, progress, answered, on-hold, ended, failed — per state the SIP message that drives the transition and the UI it implies, the statechange event contract, incoming vs outgoing entry points, failure causes, and the registration retry loop behind 'reconnecting' UI.

# 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](/concepts/voice-call-lifecycle). The two advance in parallel and never map onto each other directly — section [This page vs. the server-side lifecycle](#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:

| State      | What the user does or sees                                            | SIP that drives it                                                                     | Render                                                                             |
| ---------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `initial`  | The session object exists; outgoing INVITE not yet sent.              | Session construction (no SIP yet).                                                     | Idle dial screen with call controls disabled.                                      |
| `ringing`  | An inbound call arrived; the phone is ringing.                        | Incoming INVITE.                                                                       | Incoming-call popup with **Answer / Reject**.                                      |
| `progress` | Outgoing call is being set up; early media may be audible (ringback). | 18x provisional response (180 Ringing or 183 Session Progress) on the outgoing INVITE. | "Calling…" screen with audible ringback.                                           |
| `answered` | Two-way media established; the conversation is live.                  | 200 OK to INVITE, confirmed with ACK.                                                  | In-call screen: mute, hold, keypad, hang up.                                       |
| `on-hold`  | The local party put the remote side on hold.                          | Re-INVITE with a `sendonly` SDP offer, acknowledged.                                   | In-call screen with **Resume** instead of **Hold**.                                |
| `ended`    | The call completed normally.                                          | BYE, remote CANCEL, or a normal terminate.                                             | Call-summary screen; release call controls.                                        |
| `failed`   | The call aborted — network error, rejection, or timeout.              | A non-2xx final response, or a transport timeout under the JsSIP umbrella.             | Error surface with the cause rendered (see [Failure outcomes](#failure-outcomes)). |

`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:

```ts theme={null}
session.on("statechange", (state) => {
  renderCallState(state); // re-render per transition
});
```

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.

<Note>
  `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.
</Note>

## 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](/concepts/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:

```ts theme={null}
softphone.on("registration-failed", ({ attempt, willRetry, nextDelayMs, cause }) => {
  if (willRetry) showReconnecting(attempt, nextDelayMs);
  else showOffline(cause); // the legacy `error` event fires once here too
});
```

Tuning lives in one optional object, `registrationRetry`:

```ts theme={null}
Softphone.fromToken(jwt, {
  fromNumber: "+14155552671",
  registrationRetry: { baseMs: 1000, capMs: 60_000, maxAttempts: 8 },
});
```

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](/concepts/voice-call-lifecycle) — the server-side call FSM this page cross-references.
* [Web SDK](/sdks/web) — the softphone's full SDK surface (place, answer, hold, DTMF, transfers, recording entry points).
* [SIP credential lifecycle](/concepts/sip-credential-lifecycle) — where the short-lived SIP credentials come from and how the SDK rotates them.
