Tenant documents
A Tenant is a customer's identity. Everything that belongs to one is a
document of its own, namespaced by metadata.tenant.
Where these documents live carries no meaning — see Documents and paths. Keys are camelCase throughout, and a misspelt one is a startup error.
Tenant
configVersion: lyno/v1
kind: Tenant
metadata:
name: acme
spec:
description: Acme Industries
domains: [acme.pbx.example.com]
dids:
numbers: ["+31201234567"]
patterns: ['^\+312012345\d{2}$']
callerId:
number: "+31201234567"
name: Acme
timezone: Europe/Amsterdam
interTenant: [globex]
moh: {...}
featureCodes: {...}
dataDir: /srv/acme-data
recording: {...}
storage: {...}
voicemail: {...}
smtp: {...}
security: {...}
| Key | Type | Default | Purpose |
|---|---|---|---|
description | string | — | Free text, shown in the editor. |
domains | list | — | SIP domains this tenant's phones register with. Must be unique across tenants. |
dids | object | — | Numbers this tenant owns on the shared trunks. |
callerId | object | — | Overrides the trunk's caller ID on outgoing calls. |
timezone | string | global timezone | IANA timezone for this tenant's time conditions. |
interTenant | list | — | Tenants this one may dial. Only effective with global allowInterTenant. |
moh | object | — | Music-on-hold classes, layered over the global ones. |
featureCodes | object | — | Validated and never dispatched on. See below. |
dataDir | path | <global dataDir>/<name> | This tenant's own writable root. Written back exactly as typed. |
recording | object | global | Overrides the global block field by field. Only enabled and mode are honoured. |
storage | object | global | Merged and then ignored — the global bucket is used. |
voicemail | object | global | Overrides the global block field by field. |
smtp | object | global | Overrides the global relay field by field. |
security | object | global | limits is enforced; acl and requireSrtp are not. |
livekit | object | global | Mobile apps. Replaces the global block outright rather than merging. |
metadata.name is the tenant identifier. It is the one global kind that
namespaces others, so it carries no metadata.tenant of its own.
dids
| Key | Type | Notes |
|---|---|---|
numbers | list | Exact claims. Checked before patterns. |
patterns | list | Go regular expressions. Not anchored implicitly. |
A number claimed by two tenants is a validation error.
callerId
| Key | Type | Notes |
|---|---|---|
number | string | Presented on outgoing calls, ahead of the trunk's. |
name | string | Display name. |
moh
spec:
moh:
default: ../sounds/moh/acme.wav
classes:
jazz: ../sounds/moh/acme-jazz.wav
Layered over the global map key by key: a class defined in both resolves to the tenant's file.
featureCodes
spec:
featureCodes:
voicemail: "*97"
dndOn: "*78"
dndOff: "*79"
forwardSet: "*72"
forwardClear: "*73"
queueLogin: "*11"
queueLogout: "*12"
recordToggle: "*1"
All eight fields are validated — a code that collides with an account extension
or a dialplan extension is refused — and none of them make a number work.
Dialling *78 reaches a feature only because an Extension document is named
*78.
The one value read at run time is voicemail, and only to fill
{{.FeatureCode}} in a notification email. See
Feature codes.
Overrides of the global blocks
recording, storage, voicemail, smtp and security take the same shape
as the System blocks and override them field by
field — a key the tenant does not mention keeps the global value.
What actually takes effect per tenant:
| Block | Honoured |
|---|---|
recording | enabled and mode only. |
storage | nothing — the global bucket is used. |
voicemail | yes. |
smtp | yes. |
security | limits only. acl and requireSrtp are not enforced anywhere. |
livekit | yes — but it replaces the global block instead of merging into it, so a tenant override must be complete. |
Account
A SIP endpoint. metadata.name is the SIP username.
configVersion: lyno/v1
kind: Account
metadata:
name: alice
tenant: acme
spec:
password: "..."
extension: "100"
displayName: Alice
| Key | Type | Required | Notes |
|---|---|---|---|
password | string | yes | Digest password, stored in plain text. |
extension | string | yes | Extension this account belongs to. Several accounts may share one, and then ring in parallel. |
displayName | string | no | Caller name on internal calls. |
record | bool | no | Overrides the recording mode for calls this account answers or places. Unset follows the tenant. |
Extension
A dialplan. metadata.name is the number, which is why *78 is an
ordinary extension and feature codes need no special case.
configVersion: lyno/v1
kind: Extension
metadata:
name: "200"
tenant: acme
spec:
description: Support
steps:
- id: ring
type: dial
timeout: 20s
target: {kind: endpoint, endpoint: "100"}
| Key | Type | Required | Notes |
|---|---|---|---|
description | string | no | Free text, shown in the editor. |
steps | list | yes | Ordered. Every step type and field is in the Extension reference. |
Steps stay a list inside the document: they are a sequence by nature, and one plan is one thing to read.
RingGroup
configVersion: lyno/v1
kind: RingGroup
metadata:
name: support
tenant: acme
spec:
strategy: simultaneous
targets: ["100", "101", "external:0612345678"]
timeout: 30s
| Key | Type | Required | Notes |
|---|---|---|---|
strategy | string | yes | simultaneous or sequential. |
targets | list | yes | At least one target reference. |
timeout | duration | no | Total ring time for simultaneous; per-target time for sequential. Defaults to 30s. |
Queue
configVersion: lyno/v1
kind: Queue
metadata:
name: support
tenant: acme
spec:
strategy: fewestrecent
agents: ["100", "101"]
ringTimeout: 20s
maxWait: 5m
moh: default
announce: ../sounds/still-holding.wav
announceEvery: 45s
joinEmpty: false
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
strategy | string | yes | — | ringall, linear, roundrobin or fewestrecent. |
agents | list | yes* | — | Account extensions of this tenant. |
ringTimeout | duration | no | 20s | One attempt at one turn's agents. |
maxWait | duration | no | forever | Then the caller continues to the next step. |
moh | string | no | ringback | Class name, resolved in the tenant then globally. |
announce | path | no | — | Played periodically while waiting. |
announceEvery | duration | no | — | Requires announce. |
joinEmpty | bool | no | false | Queue a caller even when no agent is staffed. |
dynamic | bool | no | false | Accepted, and an agent can never log in. |
* Either agents or dynamic: true. See Call queues.
IVRMenu
configVersion: lyno/v1
kind: IVRMenu
metadata:
name: main
tenant: acme
spec:
prompt: ../sounds/menu-main.wav
timeout: 5s
repeat: 3
options:
"1": {type: extension, target: "200"}
"0": {type: hangup}
invalidFile: ../sounds/menu-invalid.wav
timeoutFile: ../sounds/menu-timeout.wav
onTimeout: {type: extension, target: "200"}
directDial: true
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
description | string | no | — | Free text, shown in the editor. |
prompt | path | yes | — | Played first, interruptible by the first key. |
options | map | yes* | — | Key → destination. Keys may be several digits. |
timeout | duration | no | 5s | Wait for a digit before repeating. |
repeat | int | no | 3 | Attempts before giving up. |
invalidFile | path | no | — | Played after a wrong key. |
timeoutFile | path | no | — | Played after silence. |
onInvalid | destination | no | fall through | |
onTimeout | destination | no | fall through | |
directDial | bool | no | false | Let a caller dial an extension not on the menu. |
* Either options or directDial: true.
Options stay a map: a keypad has no order beyond which key was pressed.
Mailbox
metadata.name is the extension whose messages it holds, and it must be an
account extension of the tenant.
configVersion: lyno/v1
kind: Mailbox
metadata:
name: "100"
tenant: acme
spec:
displayName: Alice
pin: "4821"
email: alice@example.com
attach: true
deleteAfterEmail: false
greeting: ../sounds/alice-greeting.wav
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
displayName | string | no | — | Used in the notification email. |
pin | string | yes | — | 4 to 8 digits, stored in the clear, compared in constant time. |
email | string | no | — | Notification address. Needs an SMTP relay. |
attach | bool | no | true | Send the recording with the notification. |
deleteAfterEmail | bool | no | false | |
greeting | path | no | — | Replaces the shipped prompts for this box. |
maxMessage | duration | no | tenant voicemail.maxMessage | |
retention | duration | no | tenant voicemail.retention |
InboundRoute
Where one of this tenant's DIDs is delivered.
configVersion: lyno/v1
kind: InboundRoute
metadata:
name: main-line
tenant: acme
spec:
priority: 10
did: "+31201234567"
destination: {type: extension, target: "200"}
| Key | Type | Required | Notes |
|---|---|---|---|
priority | int | no | Ascending; ties break on metadata.name. A catch-all belongs last. |
did | string | yes | The number as it arrives, or * for a catch-all inside the tenant. |
destination.type | string | yes | Use extension or ring_group. |
destination.target | string | yes | Extension number, or ring group name. Must exist. |
record | bool | no | Overrides the recording mode for calls on this number. |
Validation accepts all seven destination types, but only extension and
ring_group are delivered — see
Inbound routing.
OutboundRoute
The one kind that may omit metadata.tenant. With a tenant it is tried
first; without one it is the global fallback.
configVersion: lyno/v1
kind: OutboundRoute
metadata:
name: national
tenant: acme
spec:
priority: 20
pattern: '^0[1-9]\d{8}$'
strip: 1
prepend: "+31"
trunk: carrier
| Key | Type | Required | Notes |
|---|---|---|---|
priority | int | no | Ascending; first match wins, so this is load-bearing. |
pattern | string | yes | Go regular expression, matched against the dialled number. |
strip | int | no | Digits removed from the front, applied before prepend. |
prepend | string | no | Text put in front of what remains. |
trunk | string | yes | Must name a Trunk. |
See Outbound routing.
Webhook
An HTTPS endpoint that receives this tenant's events. Delivery defaults live in
System.webhooks.
configVersion: lyno/v1
kind: Webhook
metadata:
name: crm
tenant: acme
spec:
url: https://crm.example.com/hooks/lyno
secretFile: /etc/lyno/secrets/crm
events: [call.answered, call.ended]
timeout: 10s
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
url | string | yes | — | Must be https unless insecureHttp is set. |
secret | string | no | — | Signing secret. The least good option — see below. |
secretFile | path | no | — | Read at startup and trimmed. |
events | list | no | all | Subset of the event names below. |
timeout | duration | no | global webhooks.timeout | Bounds one delivery attempt. |
insecureHttp | bool | no | false | Permits a plain http URL. |
The signing secret is resolved most-secure-first:
LYNO_WEBHOOK_SECRET_<TENANT>_<NAME>, uppercased, with-and.mapped to_;secretFile;- the inline
secret.
Because that mapping can collide — a-b and a_b share one variable — two
endpoints of one tenant whose mapped names meet are refused. An endpoint may
sign nothing, so an empty result is legal.
insecureHttp exists for an endpoint on loopback or a private segment and is a
footgun anywhere else: a signing secret sent over plain HTTP is a secret on the
wire.
Event names
A closed set. A misspelt event is a startup error rather than a subscription that quietly never fires.
| Event | Fires when |
|---|---|
call.ringing | A call starts alerting. |
call.answered | A call is answered and bridged. |
call.ended | A call finishes, however it ended. |
mobile.ringing | A follow-me or external leg starts alerting. |
mobile.ended | That leg finishes. |
voicemail.received | A message is stored. |
registration.up | An endpoint registers. |
registration.down | Its registration lapses or is dropped. |
Target references
Wherever a target is accepted — ring group targets, follow-me stage targets, a
dial step's endpoint — these three forms are valid:
| Form | Meaning |
|---|---|
"101" | An account extension of this tenant. |
external:0612345678 | A number placed through the outbound routes. |
tenant:globex/900 | Another tenant's extension, when inter-tenant calling is allowed on both switches. |
Validation
RingGroup: invalid strategy; no targets; a target that is not a valid reference.InboundRoute: an invalid destination type; an unknown extension, ring group, queue, menu or mailbox; anexternaldestination with no number.OutboundRoute: a pattern that does not compile; an unknown trunk.Queue: an invalid strategy; no agents anddynamicoff; an agent that is not an account extension of the tenant; negative durations; a music class no level defines; an unreadableannounce;announceEverywith noannounce.IVRMenu: no prompt; an unreadable prompt,invalidFileortimeoutFile; no options and nodirectDial; an empty option key; a key containing something that is not a telephone key; an invalid destination; a negativerepeatortimeout.Mailbox: a name that is not an account extension of the tenant; a missing PIN, or one that is not 4 to 8 digits; a non-numeric PIN; an unreadable greeting; negative durations; an invalid email address; an email address with no SMTP relay configured globally or on the tenant.Webhook: a URL that is nothttpswithoutinsecureHttp; an unknown event name; an unreadablesecretFile; two endpoints of one tenant whose secret environment variables collide.featureCodes: a code colliding with an account extension or a dialplan extension; avoicemailcode on a tenant with no mailboxes.- The overriding blocks are validated with the same rules as the global ones.
- Target references: an extension that is not an account extension of the
tenant; an empty
external:target; atenant:reference withoutallowInterTenant, without the tenant ininterTenant, naming an unknown tenant, or naming an extension that tenant has no account for. - Across tenants: duplicate domains and duplicate DIDs.
- Any tenant-scoped document whose
metadata.tenantnames a tenant that does not exist.