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
- 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.
- A
mobile.ringingwebhook carries the room, the token, the LiveKit URL and the caller to the tenant's backend — before any SIP message could reach the phone. - The backend pushes it to the device. The app shows its incoming-call screen and joins the room.
livekit/sipholds the200until 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.
| Lifetime | Value | Why |
|---|---|---|
| Join token minted with the offer | 2 minutes | Has to outlive the longest sane ring, not the call. Staying in a room needs no token. |
mobile.ringing webhook job | 60 seconds | An event that arrives after the caller gave up is worse than nothing. |
| Room reachable through the token endpoint | 4 hours | Covers an app that reconnects mid-call. |
| One-time call grant | 60 seconds | Call 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"]
| Key | Type | Required | Notes |
|---|---|---|---|
url | string | yes | The LiveKit deployment. |
apiKey | string | yes | |
apiSecret | string | no | Inline. The least good option. |
apiSecretFile | path | no | Read at startup. Setting both is refused. |
sipHost | string | yes | Where the bridge reaches this PBX. |
sipPort | int | no | Must be in range. |
sipTransport | string | no | udp or tcp. tls is refused by name. |
transportId | string | no | Pins the SIP leg to one named listener, like a trunk. |
auth | object | no | Credentials the bridge authenticates with. |
matchIps | list | no | Extra source addresses for the bridge. |
roomPrefix | string | no | Prefix for generated room names. |
outboundTrunkId | string | no | The 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 ignoredSIP-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.
| Route | What it does |
|---|---|
POST /v1/mobile/login | Exchange credentials for a device session. |
POST /v1/mobile/token/refresh | Roll the token. |
POST /v1/mobile/logout | Revoke this device. |
GET /v1/mobile/me | Who this token belongs to. |
PUT /v1/mobile/device | Update the push registration. |
GET /v1/mobile/voicemail | This extension's messages. |
GET /v1/mobile/voicemail/{id} · /audio | One message, and its audio. |
DELETE /v1/mobile/voicemail/{id} | Delete a message. |
GET /v1/mobile/cdr | This extension's call history, cursor-paged. |
POST /v1/mobile/calls | Place a call: {"to": "..."}. |
POST /v1/mobile/livekit/token | A 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:
| Route | Scope |
|---|---|
GET /v1/mobile/devices | mobile: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 missingurl,apiKeyorsipHost;apiSecretandapiSecretFileboth set; asipPortout of range;sipTransport: tls; atransportIdnaming no listener — checked for a tenant's override too, since it replaces the global block rather than inheriting from it.