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
/phone/voice/register-deviceJWTCalled by the frontend after sign-in, before instantiating Twilio.Device. The server:
- Mints a short-lived voice access token (1 hour) with a
VoiceGrantforuser:<email> - Creates the softphone presence row
- 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
/phone/voice/heartbeatJWTRefresh 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
/phone/voice/unregister-deviceJWTCalled on logout, Device.unregister(), or beforeunload. Removes only the named device — other registrations under the same identity stay reachable.
List your devices
/phone/voice/devicesJWTReturns 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:
- The forwarding group's
fallback(voicemail, AI assistant, automation, hangup) - 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.