Recording lifecycle
Every recordable interaction on Orbit — a SIP call, a conference, an AI voice agent, a browser-softphone session, a video room — can produce a recording: a media file plus a row that tracks it. That row carries two independent axes. One is the recording’s own state machine — capture, finalization, availability. The other is a post-finalize quality verdict your tenant’s own QC pipeline writes beside it. Conflating the two is the single most common integration mistake on this surface. This page keeps them separate. The per-payload webhook contract for everyrecording.* event lives in the webhook events reference; filtering and reading recordings across classes lives in the recording and transcript library and the recordings API. This page ties them together at the concept level without restating them.
What a recording is — and which plane created it
A recording is a captured media artifact (an audio or video file in object storage) plus a durable row with a stablerecording_id, a classification saying which surface produced it, and the lifecycle state described below.
Which subsystem captures the file depends on the media plane the interaction rode — see Media planes:
- Carrier plane (SIP/PSTN) — inbound DID calls, outbound calls, and the SIPREC fork an operator toggles mid-call. Capture happens on the carrier plane as SIPREC (forked RTP written to a spool), and a separate uploader service ships the finished file to object storage and reports back. See The SIPREC uploader flow.
- Orbit Media (SFU) — video rooms and WebRTC softphone sessions. Capture happens as server-side egress on the SFU. Video-room recordings dispatch on the
video.recording.*event family; the lifecycle shape is the same.
classification is one of sbc, ai_agent, browser_softphone, conference, or video_room (webhook payloads spell the same distinction with call, conference, ai_agent, browser_softphone, video_room), and a conference recording carries conference_id where a call recording carries call_id.
The state chain
A recording that succeeds end-to-end passes through:capturing → finalized → available
Each step is owned by a different actor:
Beside the happy path:
- A capture can fail. A storage write failure, a codec mismatch, or a call that ended before any audio was captured turns the state machine terminal at
failed, reported on therecording.failedevent with a machine-readablereason. - A capture can pause and resume mid-call. Secure-payment sessions (masked keypad card capture) pause the fork for PCI-DSS descoping and resume automatically when the session ends; an operator can also pause and resume directly. A pause never changes the recording’s state — it stays
capturing; the toggle is an event, not a state.
QC verdicts — a parallel pipeline, not a state
Recording QC is the post-finalize quality-evaluation pipeline your tenant configures: it inspects the finished artifact (missing or truncated audio, empty captures, codec or duration anomalies) and writes a verdict on the recording row. Crucially, the verdict is not a state in the recording lifecycle — it rides beside it:
Consequences for an integration:
pendingis transient. A recording whose QC has not run yet (or is running) sits atpending. Never treat it as a pass or a fail — it resolves shortly after finalization and, once resolved, does not normally regress.skippedmeans QC did not evaluate this recording. The tenant pipeline was not configured to score this class or this artifact — a skipped verdict is not a degraded verdict.failedon QC is advisory, not authoritative. A QC-failed recording is still a finalized, available recording — the artifact exists and downloads. The verdict tells a reviewer the artifact has a quality problem (e.g. truncated audio), and it fires a dedicatedrecording.qc_failedevent so a triage queue can wake without polling the library.- Video rooms report the same verdict differently. The video event family breaks the verdict out as
video.recording.degraded, fired alongsidevideo.recording.completed, with{ status, reasons }on the payload. The voice-familyrecording.qc_failedcovers the unified recordings surface across every class.
qc_status, qc_failed_count) on the recording library and filterable via its qc_status parameter — qc_status=failed is the QC-triage worklist.
The event vocabulary
Recording-related webhooks fall into three groups. Keep them separate and each group’s semantics follow directly. Lifecycle — the state machine advancing, keyed onrecording_id:
Mid-call fork toggles — operator and system actions against a live capture. They carry
call_id and change no state on the recording row:
Post-finalize side channels — tenant pipelines reacting to the finished artifact. None of them change the recording’s lifecycle state:
Video rooms emit their own mirrors —
video.recording.completed, video.recording.failed, video.recording.degraded, video.recording.transcript_ready — same lifecycle shape, room-scoped payloads.
Why recording.retention_deleted exists
Retention is a tenant-owned control: you set per-channel retention windows (how long recordings and message attachments stay in Orbit’s storage before hard-delete). When a recording’s window closes, the artifact is purged from object storage — permanently. Six to twenty-four hours before that purge, Orbit fires recording.retention_deleted.
It is a pre-notification, not a post-fact audit line. The payload carries gcs_uri, a signed download_url valid until purge_scheduled_at, and the retention_policy that triggered it. The purpose is a last-grace mirror: if your compliance posture requires an archive in your own storage, this event is the guaranteed final chance to pull the artifact out before it is gone. Tenants that mirror continuously will still want this event — anything your continuous mirror missed gets one final, explicitly-signed retrieval window.
Treat the download URL as single-purpose: it expires at purge_scheduled_at, and after the purge the recording row survives (metadata, duration, QC verdict) while the artifact does not.
The SIPREC uploader flow
On the carrier plane, finalization is a pipeline, not a single action. SIPREC writes the forked media to a spool volume as raw audio, and a dedicated uploader service moves it to object storage before the completion signal can fire:- Watch — the uploader watches the spool directory and waits for each capture file to go stable (its size and modification time stop changing). A capture’s container headers are only finalized when the last media packet lands, so a file that exists is not yet a file you can ship.
- Process — per stable file, the uploader resolves its sidecar metadata (call id, timestamps, duration, channel count), falling back to the bare filename when the sidecar is absent.
- Upload — the file uploads to object storage with resumable semantics, so a long recording survives a transient network fault without restarting from byte zero. The tenant attribution rides the object metadata, not the callback payload.
- Callback — the uploader posts a signed completion callback to the API with the call id, object path, duration, and file size. Tenant resolution happens on the receiver side by call-id lookup, which is why a missing or stale tenant hint in the sidecar cannot misroute the recording.
recording.completed / call.recording.ready mean anything. Two consequences follow: an end-of-call is not a downloadable-recording guarantee (budget a short finalize window in any UI gated on “recording exists”), and a spool-side failure between step 1 and step 4 is exactly what recording.failed reports with its reason.
What to branch on
Branch integrations on machine-readable fields only:recording_id— the join key across every event in a recording’s life. Lifecycle and post-finalize events carry it; reconcile by id, never by arrival order. Call-scoped events also carrycall_id; conference recordings substituteconference_id.- Event
type, grouped as above — lifecycle events mutate the recording’s state; pause/resume toggles do not; post-finalize side channels mutate tenant-pipeline data beside it. qc_statuson the row — read it from the recording library or recordings API for the verdict. It resolves asynchronously after finalization;pendingmeans “not scored yet,”skippedmeans “not scored at all.”reasononrecording.failed— the machine-readable capture-failure cause. Keep failure-rate metrics per reason, not per generic failure event.- Retention pre-notification fields — on
recording.retention_deleted:download_url(fetch beforepurge_scheduled_at) andretention_policy(which window closed).
Common pitfalls
- Treating a QC verdict as a recording state.
qc_statuslives on the row beside the lifecycle; a QC-failed recording is stillavailable, and a QC-passed recording still gets purged at retention close. Gate download flows on the lifecycle state, gate triage queues on the verdict — never one on the other. - Waiting for
recording.completedwithout a finalize window. The completion event fires when the artifact is downloadable, not when the call or room ended — on SIPREC captures the uploader hand-off sits between the two. Poll or alert against “call ended but no completion after N minutes” if you need liveness there. - Deduping by
type. Lifecycle, toggles, and post-finalize events all repeat underneath a retrying delivery path. Dedupe byrecording_idplus eventtype, and let a later event-superseding update overwrite rather than append. - Treating QC as authoritative for the artifact’s existence. QC verdicts are advisory: a triage queue on
recording.qc_failedcatches artifacts worth a reviewer’s eyes. Blocking download or display on afailedverdict loses recordings that are imperfect but usable — for video rooms,video.recording.degradedcarries per-checkreasonsso you can decide. - Ignoring
recording.retention_deleteduntil the purge. The signed download URL dies with the artifact atpurge_scheduled_at. Mirroring after purge is not a recovery path — the artifact is gone. - Assuming one completion signal per recording class. Call-scoped recordings fire
call.recording.ready; conference-class recordings can’t (there is nocall_id), which is whyrecording.completedexists as the classification-agnostic alias. Subscribe to both, or torecording.completedalone withclassificationas your routing key.