Documentation

Softphone presence

Track which Twilio Voice devices are live for each user; route incoming calls only to ones that are online.

SoftphonePresenceService tracks which Twilio.Device instances (browser tabs, iOS apps, Android apps) are currently registered for a given user identity. When a call rings the user, AppEngine consults presence and only dials the devices that are actually online.

Identity model

A user's softphone identity is built by PhoneService.buildUserVoiceIdentity():

identity = `user:${email}`

The same identity is used in TwiML when the IVR forwards a call: <Dial><Client>user:[email protected]</Client></Dial>. Twilio fork-rings every device registered under that identity.

One row per device

Storage layout:

softphone:{orgId}:{identity}:{deviceId}
  → JSON { identity, deviceId, platform, label, userEmail, userAgent, ip,
           capabilities, lastSeen }

The deviceId comes from the frontend — generated once and persisted in localStorage (web), Keychain (iOS), or EncryptedSharedPreferences (Android). Multiple tabs of the same browser profile share one deviceId because Twilio enforces "one Device per identity per browser" anyway.

TTL is 90 seconds. The frontend heartbeats every ~60 seconds to keep the device live.

Register a device

POST/phone/voice/register-deviceJWT

Called by the frontend after sign-in, before instantiating Twilio.Device. The server:

  1. Mints a short-lived voice access token (1 hour) with a VoiceGrant for user:<email>
  2. Creates the softphone presence row
  3. Returns the assigned phones (direct or via group) so the UI knows which numbers ring here
{
  "deviceId": "dev_a1b2c3",
  "platform": "web",
  "label": "Chrome on MacBook",
  "capabilities": ["voice", "sms"]
}

Response:

{
  "identity": "user:[email protected]",
  "token": "<jwt>",
  "expiresIn": 3600,
  "assignedPhones": [
    { "id": "phone-...", "phoneNumber": "+14155551234", "friendlyName": "Sales", "assignment": "direct" }
  ]
}

Heartbeat

POST/phone/voice/heartbeatJWT

Refresh one device's TTL. Send the same deviceId from register-device. Optionally update capabilities if the user's preferences change.

{ "deviceId": "dev_a1b2c3", "platform": "web", "capabilities": ["voice", "sms"] }

Unregister

POST/phone/voice/unregister-deviceJWT

Called on logout, Device.unregister(), or beforeunload. Removes only the named device — other registrations under the same identity stay reachable.

List your devices

GET/phone/voice/devicesJWT

Returns every softphone currently registered for the calling user. Shows on the user's settings page so they can see "I have a session running on my office desktop and my iPhone".

Capabilities: voice vs SMS

The capabilities array tells the backend what each device wants to receive:

  • voice — incoming calls. TwiML <Dial><Client> only dials devices with this set.
  • sms — inbound SMS for the user's assigned numbers, pushed via the chat / notification websocket.

Defaults to ['voice'] when omitted. Pass ['voice', 'sms'] for a full softphone, or ['sms'] for a desktop without a microphone (SMS-only inbox).

Routing decision

When a call comes in, the IVR / direct-routing flow gathers the destination user(s), reads their live softphones, and emits <Dial><Client> for each one. Devices that aren't in presence are skipped — Twilio doesn't even attempt to dial them.

If no device is live for the target user, the routing falls back to:

  1. The forwarding group's fallback (voicemail, AI assistant, automation, hangup)
  2. Otherwise, the number's voicemail config

Multi-device behavior

Multiple devices = parallel ring. First one to answer wins; the others stop ringing automatically. The agent app should handle the Device.disconnect event for non-winners gracefully (no error toast — they didn't fail, they were beaten).

The assignedPhones[] array returned by register-device is the same one returned by GET /phone/user-phones. Call the latter to refresh assignments without re-issuing a token.