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:
mode | Records |
|---|---|
none (or unset) | nothing |
inbound | calls that arrived on a trunk |
outbound | calls that left on a trunk |
internal | calls between extensions |
all | everything |
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
| Level | Where |
|---|---|
| 1. Per inbound route | record: on an InboundRoute |
| 2. Per account | record: on an Account |
| 3. Per tenant | recording.enabled / recording.mode on the Tenant |
| 4. Global | recording.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_id | Identity. The SIP Call-ID is a field here, never a path element. |
direction, from, to, did, trunk, answered_by | Who and how. |
start, end, duration_ms | When. duration_ms is the length of the audio, not of the call. |
format | Container, codec, sample rate, channels, and the channel layout. |
bytes, sha256 | What, 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: s3The 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 ignoredThe 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 nothingThe 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 key | Effect |
|---|---|
recording.enabled, recording.mode | works |
recording.keepLocal, recording.maxPending, recording.storage | ignored |
the tenant's whole storage.s3 block | ignored — 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.yaml → recording, storage.