Skip to main content

Call recording

configVersion: lyno/v1
kind: System
spec:
dataDir: /var/lib/lyno

recording:
enabled: true
mode: all
storage: local
keepLocal: true
maxPending: 500

Recording is off by default and needs a dataDir. Finished recordings land in <dataDir>/recordings/YYYY/MM/DD/ — at the root, not under a tenant. Every tenant's recordings share that tree and are told apart by the tenant field in each sidecar.

Which calls are recorded

mode is the base answer, matched against the call's direction:

modeRecords
none (or unset)nothing
inboundcalls that arrived on a trunk
outboundcalls that left on a trunk
internalcalls between extensions
alleverything

Four levels can override it, most specific first — the first one that says anything wins:

configVersion: lyno/v1
kind: Account
metadata: { name: alice, tenant: acme }
spec:
password:
extension: "100"
record: false # never record what Alice answers
---
configVersion: lyno/v1
kind: InboundRoute
metadata: { name: support-line, tenant: acme }
spec:
priority: 10
did: "+31201234567"
destination: {type: extension, target: "200"}
record: true # always record the support line
LevelWhere
1. Per inbound routerecord: on an InboundRoute
2. Per accountrecord: on an Account
3. Per tenantrecording.enabled / recording.mode on the Tenant
4. Globalrecording.mode on System

A DID with record: false beats a tenant on mode: all. The account override is resolved from whichever leg actually answered.

Setting enabled: false collapses the mode to none regardless of what mode says.

A record dialplan step can arm or disarm one specific call. It arms rather than opens — the file is created when the call is bridged — so the step must sit in front of the dial that creates the far leg.

What a recording is

A two-channel WAV: the caller on the left, the far end on the right, 16-bit PCM at the negotiated codec's rate (8 kHz in practice). Both directions arrive still encoded and are decoded per side; there is no mixing, so a recording can be split back into two mono legs.

Which party is on which channel is invisible in the audio, so it is written down in the sidecar rather than implied.

Beside every <id>.wav sits an <id>.json:

Field
id, tenant, call_idIdentity. The SIP Call-ID is a field here, never a path element.
direction, from, to, did, trunk, answered_byWho and how.
start, end, duration_msWhen. duration_ms is the length of the audio, not of the call.
formatContainer, codec, sample rate, channels, and the channel layout.
bytes, sha256What, exactly.

The sidecar is written after the audio and uploaded after it, so its presence is what says the recording next to it is whole. A file left in recordings/inflight/ is the mark of a crash.

If the two directions drift more than two seconds apart, the lagging side is written against silence rather than allowed to skew.

Recording never fails a call

Every failure is logged and stepped over. A call that drops because a disk filled is a far worse outcome than a call nobody has a recording of.

maxPending is the same rule written down: past that many queued uploads the PBX stops recording — it does not stop answering.

maxPending only bites with storage: s3

The backlog it counts is the upload queue, and there is no queue when recordings stay on local disk. With storage: local the count is always zero and the cap never fires.

Object storage

configVersion: lyno/v1
kind: System
spec:
recording:
storage: s3
keepLocal: true

storage:
s3:
bucket: lyno-recordings
region: eu-central-1
endpoint: ""
pathStyle: false
sse: AES256
keyPrefix: "{tenant}/recordings/{yyyy}/{mm}/{dd}/{callid}.wav"

Finished files are queued and uploaded in the background. keepLocal: true (the default) keeps the local copy after a confirmed upload — deleting the only copy of a conversation should be something you ask for.

Leaving accessKeyId empty falls through to the environment, the shared credentials file or an instance role, which is the better way to run this since these files are also the ones holding SIP passwords. Setting one of accessKeyId / secretAccessKey without the other is a validation error.

For a store that is not AWS, set endpoint and usually pathStyle: true: MinIO, Cloudflare R2 (which wants region: auto), Wasabi.

The key template

Tokens are {tenant}, {callid}, {ext}, {kind}, {yyyy}, {mm}, {dd}, {HH} and {MM}. They are case-sensitive: {mm} is the month, {MM} is the minute. An unknown token is a validation error rather than a bucket full of objects under a directory called {tennant}.

The default layout matches the local tree, so a bucket and a local directory are interchangeable and aws s3 sync is a valid backup.

Every substituted value is sanitised, so a hostile Call-ID cannot add a path level.

The upload queue

Jobs are JSON files under <dataDir>/uploads/pending/, so a restart resumes where it left off. Two uploads run at a time. A failure backs off from 30 seconds, doubling to a 30-minute ceiling with jitter.

A job that has failed for seven days, or whose local file has vanished without the object ever landing, is moved to uploads/failed/ and logged at error level — never deleted, and the local file is never removed. An unparsable job file is quarantined the same way.

storage.s3.maxRetryAge is ignored

The key is accepted, validated and editable, and it is never passed to the upload queue. The seven-day give-up is a constant. Setting maxRetryAge: 24h changes nothing.

Nothing surfaces the failed-upload count — check uploads/failed/ yourself.

Retention is not applied

recording.retention does nothing

The key is validated and the editor offers it, but there is no recording janitor. Call logs and voicemail are pruned; recordings are not, and they grow forever.

Prune them yourself. The day-directory layout makes that a one-liner:

find /var/lib/lyno/recordings -mindepth 3 -maxdepth 3 -type d -mtime +365 -exec rm -rf {} +

Recordings are the one thing in the data directory that grows without bound, so give it its own volume.

Per-tenant settings that are ignored

A tenant may override the whole recording: and storage: blocks, and the merge happens — but only enabled and mode reach the recorder. There is one recorder for the process, and it reads the global configuration for everything else:

Per-tenant keyEffect
recording.enabled, recording.modeworks
recording.keepLocal, recording.maxPending, recording.storageignored
the tenant's whole storage.s3 blockignored — the global bucket is used

A tenant naming its own bucket gets the global one. That matters if you were planning to separate customers' recordings by bucket; separate them by {tenant} in the key template instead.

recording.beep does nothing

The key exists because several jurisdictions require a periodic tone, and it is accepted, merged and editable — but nothing plays a tone. If you need an audible notice, put a play step in front of the dial.

Getting recordings out

Over the management API with the recordings:read scope:

curl -H "Authorization: Bearer $T" \
'http://127.0.0.1:8080/v1/recordings?tenant=acme&from=2026-07-01T00:00:00Z'

curl -H "Authorization: Bearer $T" -o call.wav \
http://127.0.0.1:8080/v1/recordings/acme/20260726T143012Z-3f9ac1d2/audio

Downloads from local disk support Range requests. Do not turn on api.recordings.presign — it makes every download fail; see the API page.

Reference

Key-by-key schema: lyno.yamlrecording, storage.