Errors worth branching on
These failures cover the recording playback read (GET /api/v1/voice/calls//recording), hammered by every call-detail view and player. Each block below is a full { error, meta } envelope as the API returns it, and the matrix at the bottom answers retry vs surface for the same classes, including the expired-recording case. For the platform-wide decision table these branches plug into, see the error handling guide.
401 — Unauthorized
A 401 on this page is the bearer key failing before the route ran — it never means the resource is wrong. Rotate the key or re-mint the scoped token; retrying the same request changes nothing.
403 — Forbidden
A 403 means the key authenticated but the operation is gated by scope — check the key’s scopes on the developer page; a 403 is never a data-not-found shape.
422 — Schema
A 422 means the payload did not match the request schema — branch on error.details.field and check the call row for recording_id with a GET first, then fetch the file endpoint instead of blind-retrying the same body.
429 — Rate
The Retry-After header and error.details.retry_after are both set on every 429 — resend the SAME request after the lower of the two.
404 — Not found
Retry with backoff — the uploader typically attaches within seconds of call end.
404 — Expired
The not-ready 404 above says “retry with backoff.” This one says “gone” — the tenant’s retention sweep has already deleted the media. Stop retrying; the only recovery is raising the retention window before the next sweep runs, or exempting the recording with a legal hold before the window closes.
60-second retry matrix
Worked request and response samples
Copy the request as written, substitute your own ids, and compare the response envelope — a success in the same shape the numbered error sections spell out. The samples cover the full playback path, in the order a call-detail view walks it: list → fetch → conversation scope → BYO delivery → retention posture.
1. List recordings for a call
Page through calls that carry recordings, filtered by the call ids you already have. Each row carries a signed, playable recording_url minted on read (never the raw gs:// object path), plus the duration, format, and expiry you can render next to the player.
Request
Keep paging on pagination.cursor until has_more flips false. A call whose uploader is still attaching returns recording_url: null — re-poll after a few seconds rather than branching on the null.
2. Fetch the file
GET /api/v1/voice/calls/{id}/recording
Returns a signed, time-limited playback URL for one call’s recording. The binary bytes never cross your JSON envelope — the response carries a V4-signed URL straight to the storage object, so the player downloads the media directly from the CDN layer while your API request stays a few hundred bytes. The expires_at epoch-ms tells the UI when the fetch link dies (a re-GET re-mints it).
Request
A call that was never recorded returns the 404 above. When chapters is non-empty it carries [{ start_ms, end_ms, title, summary }] markers the player renders as click-to-seek headings.
3. Conversation chain
GET /api/v1/recordings/conversation/{conversationId}/legal-hold
A conversation can span several recordings — every hold, transfer, agent leg, each lands its own row on the recordings table. This read returns the aggregated hold tally across every recording linked to the conversation, ordered by the same parent link the per-recording write uses, so you can see at a glance whether the chain is fully held before a retention sweep claims anything on it.
Request
held_recordings < total_recordings means at least one row in the chain survives un-protected — hold the whole chain with PUT /api/v1/recordings/conversation/{conversationId}/legal-hold instead of holding the three ids one by one.
4. Ship to BYO storage
POST /api/v1/video/rooms-scheduled (and POST /api/v1/video/rooms-scheduled/{id}/recording/start)
Video-room recordings can land in your own bucket instead of Orbit-managed storage. Pass recording_storage on room create (or on recording start) and every artifact for that room egresses to the destination you declare; the recording row’s destination then reports the non-secret descriptor so the audit trail answers “where does the file live” without ever storing the key.
Request
The credential you send needs, at minimum, s3:PutObject on the destination bucket (and s3:GetObject if your flow re-reads the artifact). Restrict the bucket’s IAM policy to the egress use and let bucket lifecycle rules own retention — Orbit delivers the artifact; expiry and versioning stay with you. The full destination contract (S3, GCS, S3-compatible endpoints, region pinning on Orbit-managed storage) is in the BYO storage guide.
5. Tenant-owned retention posture
PUT /api/v1/settings/retention
Retention on recordings is owned by you, not the platform: this is the one knob the scheduled sweep reads, and a partial update merges with what’s stored instead of resetting sibling channels. The response returns the merged override plus the resolved effective values, so you never need a follow-up GET to learn what the sweep will actually do.
Request
Owner/admin only. Once a sweep deletes media past a window, no placeholder remains waiting — the expired-404 above fires on the next read. Place the hold before the window closes, or widen the window here; the legal-hold and retention controls are the tenant-owned posture the compliance guide expects you to drive.
6. Share a recording
POST /api/v1/recordings/{id}/share
Mint a time-boxed share link; until the link expires, the same recording streams without an API key. Substitute the recording id for {id}.
Request
Per-operation success envelopes (concrete shapes)
Each block below fills what used to render as “data”: on this page — the concrete envelope the matching route returns. These blocks moved here from the generated endpoint file (which regeneration owns) so they survive every splice.
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Sample
Worked operator samples — QC score, view analytics, hold lifecycle
The sections above cover the playback and artefact chain; these three continue with the operator side: scoring a recording, the view telemetry your player feeds, and the legal-hold posture the retention sweep reads. Same envelope rules apply — { data, meta } on success, { error, meta } on failure.
7. Score a recording programmatically
POST /api/v1/recordings/{id}/qc/run (read-back: GET /api/v1/recordings/{id}/qc)
Re-runs the QC scorer synchronously against the recording’s current attributes and persists the verdict. The operation takes no request body — the recording id in the path selects the row, so there is nothing to POST beyond the headers. It is idempotent: re-running an already-passed recording returns the same verdict and emits no recording.qc_failed webhook. Requires the developer role or above and the voice:write scope; GET /{id}/qc only needs voice:read.
Request
Branch on qc_status, not on the HTTP status — the run returns 200 whether the verdict is passed or failed; the status-code branches are 404 for an unknown id and 400 for a malformed one. GET /{id}/qc returns the same envelope plus classification (null until scored) and reports qc_status: "pending" for a row that has never been scored, so a dashboard can render “QC not yet run” without treating it as a miss.
8. Views and view analytics
POST /api/v1/recordings/{id}/views · GET /api/v1/recordings/{id}/views/report · POST /api/v1/recordings/{id}/desktop-analytics
Your playback widget reports each viewer’s playback events; the API rolls them into a live viewership monitor and an engagement report with the drop-off curve. viewer_id is an opaque session id your player mints — never an email address or user id — so the telemetry carries no personal data.
Request
The 200 doubles as the player’s “watching now” badge — no second read needed. Event ingest is best-effort: a backing-store outage returns 503 SERVICE_UNAVAILABLE, never 500; do not block the player on a retry or treat the 503 as a playback failure.
The rollup read is GET /{id}/views/report — the watch-rate figures plus the audience-retention curve (how many viewers reached each percentile of the recording: your drop-off chart) and a bounded per-viewer breakdown:
The agent-side counterpart is POST /{id}/desktop-analytics: your desktop agent uploads the raw application-usage segments it captured during the call, and the API derives the report server-side (time-in-application, non-talk split, swivel-chair transitions, automation opportunities). The body carries agent_id plus a segments array; every other field in the response is derived:
The 200 returns { recording_id, desktop_analytics, desktop_analytics_count } where desktop_analytics is the persisted record shown in full in the per-operation sample above (derived report with applications, top_transitions, automation_opportunities, window). A recording already at the per-recording record limit answers 400 DESKTOP_ANALYTICS_LIMIT_REACHED — that branch means drop or replace your oldest record, not retry.
9. Legal hold lifecycle
PUT /api/v1/recordings/{id}/legal-hold
Placing a hold exempts the recording from the age-based retention sweep (media object and DB row), so it survives past the window the expired-404 above describes. The hold persists until you release it — there is no expiry field and no separate DELETE operation on this surface; releasing is the same PUT with hold: false. Owner/admin role only.
Request
Recording under hold — the branch to code
The hold is a sweep exemption, not a write gate: while legal_hold is true, redaction-region writes and share revocation still succeed at the HTTP layer. The branch a compliance integration must code is therefore client-side — gate any destructive call on a GET /{id}/legal-hold read, and treat legal_hold: true as “do not touch until released.” If media under a seal does change, the integrity verify flips to valid: false with mismatched hashes:
Release with hold: false and the next sweep pass may reclaim the row once it is past the window — the expired-404 above resumes applying from that point. Place holds before the window closes, not after the sweep has run.
Worked sequence: use a recording id end-to-end
The blocks above document each operation on its own; this sequence runs one recording, rec_01J8ZC3NPA, through the post-call pipeline in the order a regulated operation usually runs it, matching the post-call pipeline guide. Every response below uses the same { data, meta } envelope as the rest of this page. For the registry reads (list, media, transcript segments) pair this sequence with the /api-reference/recordings surface; once share links go out, pair it with recording view analytics.
1. Confirm the recording exists
GET /api/v1/recordings/{id}
Fetch the row before anything else. status: ready means finalize completed and the media is addressable; while the uploader is still attaching, the fetch returns the RECORDING_FILE_NOT_READY 404 from Errors worth branching on, so retry with backoff instead of treating it as a missing call.
Response
content_url_expires_at is when the signed playback URL lapses; request the row again to mint a fresh one rather than caching it.
2. QC verdict
GET /api/v1/recordings/{id}/qc
Read the verdict the post-call pipeline persisted at finalize. A failed check names the defect in checks[].message; failed_count / passed_count summarize the run.
Response
POST /api/v1/recordings/{id}/qc/run
Re-run QC synchronously after re-processing the media. The call persists the new verdict and returns it; a failure also fans out the recording.qc_failed webhook, while a pass on an already-passed recording returns the same verdict and emits nothing, which makes the re-run safe to retry.
Response
3. Legal hold + integrity
PUT /api/v1/recordings/{id}/legal-hold
Place the hold before exporting anything: it exempts the media object and the row from age-based retention sweeps. Send "hold": false to release; reason is optional and capped at 512 characters.
Request
Response
POST /api/v1/recordings/{id}/integrity/seal
Seal the held recording to anchor its content into the tenant hash chain. The response returns the seal hash triplet plus digest_url, the signed provenance bundle to ship alongside any exported media; GET /api/v1/recordings/{id}/integrity/export-digest downloads it, and GET /api/v1/recordings/{id}/integrity/verify re-hashes the seal on demand.
Response
Sealing before finalize returns RECORDING_INTEGRITY_MISSING_CONTENT_HASH; wait for finalize or pass content_sha256 (64-char hex) in the request body.
4. Caption export and clips
GET /api/v1/recordings/{id}/transcript/export?format=vtt&pseudonymize=true
The export streams the caption file itself, not a JSON envelope. With pseudonymize=true, email, phone, and long-ID tokens are replaced by [EMAIL], [PHONE], and [ID] before rendering, and X-Pseudonymized-Count reports what was scrubbed; raw output never sets the header.
Request
Response
POST /api/v1/recordings/{id}/clips
Cut a named clip by range; the 201 response returns the clip with a media_fragment your player appends to the recording URL to seek straight to the window.
Request
Response
POST /api/v1/recordings/{id}/highlights/detect
Detection scans the transcript and returns the proposal set in the same call; re-running replaces the prior set. Review the persisted proposals afterwards with GET /api/v1/recordings/{id}/highlights (the poll URL for later review), promote with POST /api/v1/recordings/{id}/highlights/{momentId}/clip, and drop noise with DELETE /api/v1/recordings/{id}/highlights/{momentId}.
Response
Detection needs a completed transcript; without one it returns RECORDING_HIGHLIGHT_NOT_TRANSCRIBED.
5. Public share links
POST /api/v1/recordings/{id}/share
Mint a time-limited public link for an external auditor or counsel. Lifetimes clamp between 5 minutes and 30 days; omit expires_in_seconds for the 7-day default. The examples below request 72 hours.
Request
Response
DELETE /api/v1/recordings/{id}/share
Revoke every outstanding link in one write; prior links fail immediately and new links can be minted afterwards. Public playback of a live link still registers in view analytics, so you can confirm the auditor watched before closing the matter.
Response