Skip to main content

Mobile apps

A native app becomes one more phone on an extension. When a call forks to that extension the PBX offers one extra leg toward a LiveKit SIP bridge, and the app joins the room it is told about.

This needs three things: a LiveKit deployment, a webhook endpoint the tenant's own backend listens on, and a backend that turns the wake-up event into an APNs or FCM push. Lyno does not talk to Apple or Google itself.

How a call reaches the app

  1. A call reaches an extension and forks to its phones. If the extension has a registered device, the coordinator adds one leg toward the LiveKit SIP bridge and mints a room-scoped join token.
  2. A mobile.ringing webhook carries the room, the token, the LiveKit URL and the caller to the tenant's backend — before any SIP message could reach the phone.
  3. The backend pushes it to the device. The app shows its incoming-call screen and joins the room.
  4. livekit/sip holds the 200 until a participant publishes, so the SIP leg answers only once the app is actually in the room.

That last point is why this needs no special case in the fork logic: the existing "first answer wins, cancel the rest" race stays correct, because the mobile leg does not answer until somebody really picked up.

If the offer fails or is cancelled, the room is deleted after five seconds — long enough for a late 200 to settle. A mobile.ended webhook follows with the outcome, so the backend can dismiss the call screen.

LifetimeValueWhy
Join token minted with the offer2 minutesHas to outlive the longest sane ring, not the call. Staying in a room needs no token.
mobile.ringing webhook job60 secondsAn event that arrives after the caller gave up is worse than nothing.
Room reachable through the token endpoint4 hoursCovers an app that reconnects mid-call.
One-time call grant60 secondsCall setup either happens in seconds or has already failed.

Configuring LiveKit

The block sits on System, and a tenant may override it. It is a pointer on both: an override replaces the whole block rather than merging field by field, so "said nothing" stays distinguishable from "switched off". No block at all means the PBX talks to no LiveKit deployment.

configVersion: lyno/v1
kind: System
spec:
livekit:
url: https://lyno.livekit.cloud
apiKey: APIxxxxxxxx
apiSecretFile: /etc/lyno/secrets/livekit
sipHost: sip.lyno.example.com
sipPort: 5060
sipTransport: udp
roomPrefix: lyno-
outboundTrunkId: ST_xxxxxxxx
matchIps: ["198.51.100.20"]
KeyTypeRequiredNotes
urlstringyesThe LiveKit deployment.
apiKeystringyes
apiSecretstringnoInline. The least good option.
apiSecretFilepathnoRead at startup. Setting both is refused.
sipHoststringyesWhere the bridge reaches this PBX.
sipPortintnoMust be in range.
sipTransportstringnoudp or tcp. tls is refused by name.
transportIdstringnoPins the SIP leg to one named listener, like a trunk.
authobjectnoCredentials the bridge authenticates with.
matchIpslistnoExtra source addresses for the bridge.
roomPrefixstringnoPrefix for generated room names.
outboundTrunkIdstringnoThe LiveKit outbound trunk used to call back into the PBX.

LYNO_LIVEKIT_API_SECRET wins over apiSecretFile, which wins over apiSecret — the same order the TTS key and webhook secrets use. Leaving all three empty is valid, because the environment may supply it at runtime.

sipTransport: tls is refused, not ignored

SIP-TLS listeners are not wired through to the SIP stack, so a tls value is rejected at startup rather than accepted and quietly downgraded. A transport that validates and then goes out in the clear is the worse failure — the same reasoning that keeps the srtp and wss settings honest. See what does not work.

Devices and their tokens

An installation logs in once with the SIP domain, an account username and password, and a device id:

POST /v1/mobile/login
{"domain": "acme.pbx.example.com", "username": "alice", "password": "...",
"device": {"id": "...", "platform": "ios", "push": {"apns": "..."}}}

platform must be ios or android. What goes in push is between the app and the tenant's backend — Lyno stores it and hands it back, it never contacts a push service.

The bearer token is deliberately not a JWT. A JWT is valid until it expires no matter what happens in between, and "this phone was stolen, log it out now" is a requirement here rather than a nice-to-have. A random token checked against a stored SHA-256 digest is revoked by deleting one record; a signed claim cannot be. The digest is plain SHA-256 for the same reason API tokens use it: the token is high-entropy random already, so a slow key derivation buys nothing and turns every request into a way to burn CPU.

A failed login charges a per-account rate-limit bucket as well as the per-source one, so a botnet rotating addresses against a single account still runs out of budget.

The device API

Everything under /v1/mobile runs on a device token, not an operator token. Login is public — the credentials are the body.

RouteWhat it does
POST /v1/mobile/loginExchange credentials for a device session.
POST /v1/mobile/token/refreshRoll the token.
POST /v1/mobile/logoutRevoke this device.
GET /v1/mobile/meWho this token belongs to.
PUT /v1/mobile/deviceUpdate the push registration.
GET /v1/mobile/voicemailThis extension's messages.
GET /v1/mobile/voicemail/{id} · /audioOne message, and its audio.
DELETE /v1/mobile/voicemail/{id}Delete a message.
GET /v1/mobile/cdrThis extension's call history, cursor-paged.
POST /v1/mobile/callsPlace a call: {"to": "..."}.
POST /v1/mobile/livekit/tokenA fresh join token for a room: {"room": "..."}.

A room the extension was never offered answers 404, never 403. A room name is a record id, and confirming that one exists for somebody else would turn the endpoint into an enumerator.

Two operator routes sit alongside them, on an ordinary API token:

RouteScope
GET /v1/mobile/devicesmobile:read
DELETE /v1/mobile/devices/{tenant}/{deviceId}mobile:manage

How an app-originated call comes back

POST /v1/mobile/calls does not place the call directly. The PBX mints a room and a single-use grant, then asks LiveKit to create a SIP participant that dials back into the PBX with the grant in an X-Lyno-Token header — livekit/sip forwards X-* headers onto the INVITE verbatim.

When that INVITE arrives, the grant is the authorisation, not the source address. Operator-configured trunks are trusted by IP, but the bridge is shared infrastructure whose egress may not even be pinnable on LiveKit Cloud. What proves the call is that a live, authenticated app session asked for exactly this destination less than a minute ago. The address only selected the code path.

Grants live in memory only, by design. A grant that does not survive a restart is a call setup that fails loudly within 60 seconds — the app sees the call not connect and retries. A persisted grant would be a stored credential needing its own sweeping, revocation and 0600 story, for no benefit.

Validation

  • livekit: a missing url, apiKey or sipHost; apiSecret and apiSecretFile both set; a sipPort out of range; sipTransport: tls; a transportId naming no listener — checked for a tenant's override too, since it replaces the global block rather than inheriting from it.