> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Operate the post-call recordings pipeline: QC, legal hold, integrity seals, captions, clips, and highlights

> Run the full post-call workflow on a recording — quality control, legal hold, tamper-evident integrity seals, caption export with PII scrubbing, clips, auto-detected highlights, and auditor share links — through the recordings API.

# Operate the post-call recordings pipeline

When a call or video room ends, the recording goes through a finalize step and lands in your [recording library](/voice/recording-library). Everything after that point — scoring the recording's quality, preserving it for litigation, proving it has not been tampered with, exporting its transcript, and cutting shareable clips — is a set of tenant-owned controls on the `/api/v1/recordings` surface. This guide walks the whole pipeline in the order a regulated call-center operation usually runs it.

Every endpoint below takes a recording ID from the recordings list (see the [recordings API reference](/api-reference/recordings)). All of them authenticate with your API key or dashboard session and require the `voice:read` scope for reads; write actions additionally require `voice:write`, and the compliance-governance actions (legal hold, integrity seal, QC re-run) are gated to owner/admin (QC re-run also allows developer). The share-link surface is the one exception: it governs video-room recordings and uses the `video:read` / `video:write` scopes.

## The pipeline at a glance

| Stage           | What it does                                                                       | Endpoint                                                                                                                                                                    | Scope / role                                                |
| --------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Quality control | Scores a finalized recording for capture defects; failures raise a webhook         | `GET /recordings/:id/qc`, `POST /recordings/:id/qc/run`                                                                                                                     | `voice:read`; re-run: `voice:write` + developer/admin/owner |
| Legal hold      | Exempts a recording — or every recording on a conversation — from retention sweeps | `GET/PUT /recordings/:id/legal-hold`, `GET/PUT /recordings/conversation/:conversationId/legal-hold`                                                                         | `voice:read`; writes: `voice:write` + owner/admin           |
| Caption export  | Downloads the transcript as WebVTT or SubRip, with optional PII pseudonymization   | `GET /recordings/:id/transcript/export`                                                                                                                                     | `voice:read`                                                |
| Integrity seal  | Records a tamper-evident hash-chain seal and emits a signed provenance digest      | `GET /recordings/:id/integrity`, `POST /recordings/:id/integrity/seal`, `GET /recordings/:id/integrity/verify`, `GET /recordings/:id/integrity/export-digest`               | `voice:read`; seal: `voice:write` + owner/admin             |
| Clips           | Cuts named clips with player-seek timestamps                                       | `POST/GET /recordings/:id/clips`, `DELETE /recordings/:id/clips/:clipId`                                                                                                    | `voice:read`; writes: `voice:write`                         |
| Highlights      | Auto-detects key moments from the transcript for review                            | `POST /recordings/:id/highlights/detect`, `GET /recordings/:id/highlights`, `POST /recordings/:id/highlights/:momentId/clip`, `DELETE /recordings/:id/highlights/:momentId` | `voice:read`; writes: `voice:write`                         |
| Share links     | Mints and revokes expiring public links for external viewers                       | `POST/DELETE /recordings/:id/share`                                                                                                                                         | `video:write` (video-room recordings)                       |

## 1. Quality control on recordings

QC scores a finalized recording for capture defects: files too small to hold audio, durations too short to match the call, drift between the expected and actual length, and finalize latency. The post-call pipeline runs QC automatically when a recording finalizes; these endpoints let you read the verdict and re-run it.

### Read the verdict

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/qc" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "recording_id": "rec_01J8ZC3NPA",
    "qc_status": "failed",
    "qc_report": {
      "failed_count": 1,
      "passed_count": 4,
      "checks": [
        { "name": "duration_drift", "passed": false, "message": "recorded duration is 38% shorter than the call" },
        { "name": "file_size", "passed": true, "message": null }
      ]
    },
    "classification": null
  },
  "meta": { "request_id": "req_9f3c", "timestamp": "2026-08-29T14:02:11Z" }
}
```

`qc_status` is one of `pending` (never scored), `passed`, `failed`, or `skipped`. A `pending` verdict returns 200 with an empty report — a 404 means the recording ID itself is unknown.

### Re-run QC

Re-running is synchronous and idempotent: running it on a passed recording returns the same verdict and emits **no** webhook. On failure it persists the verdict and fans out a `recording.qc_failed` webhook to your configured endpoints — so treat that webhook as at-least-once and key your handler on `(recording_id, qc_status)`.

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/qc/run" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

A re-run requires `voice:write` and a developer, admin, or owner role, and it is audit-logged.

### Triage with the `recording.qc_failed` webhook

Subscribe to `recording.qc_failed` on the **Developers → Webhooks** screen (event catalog at [webhook events](/reference/webhook-events)). A minimal handler that flags failed recordings for review:

```js theme={null}
// POST your-webhook-endpoint — verify the signature first (see webhook consumer guide)
export async function handleWebhook(req, res) {
  const event = req.body;
  if (event.type === "recording.qc_failed") {
    const { recording_id, qc_report } = event.data;
    // Triage: route to a review queue with the failed check names.
    // Common findings: crackle or dropout (duration drift), silence
    // (empty capture), truncated upload (finalize latency).
    await reviewQueue.add(recording_id, {
      failedChecks: qc_report.checks.filter((c) => !c.passed).map((c) => c.name),
    });
  }
  res.status(200).send("ok");
}
```

The event fires only on failure — silence means a pass. Full consumer setup (signature verification, retries) is in the [webhook consumer guide](/guides/webhook-consumer).

## 2. Legal hold on recordings

A legal hold exempts a recording from the age-based retention sweeps — both the media object and the database row — so it survives litigation preservation or a regulator's request. This is the recording-side twin of the conversation hold documented in [Legal holds on messaging conversations](/compliance/legal-hold), which hands recordings off without a follow-up; this guide is that follow-up. Holds are tenant-owned governance controls: owner/admin role plus `voice:write`, and every change is audit-logged.

### Per recording

```bash theme={null}
# Place a hold
curl -X PUT "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/legal-hold" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hold": true, "reason": "Matter 2026-118 — customer dispute"}'
```

```bash theme={null}
# Read current state
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/legal-hold" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

The response returns `legal_hold`, `reason`, `updated_by`, and `updated_at`. To release, send `"hold": false`. The `reason` is optional and capped at 512 characters.

### Batched by conversation

When a dispute covers a whole conversation, hold every linked recording — voice call legs and video-room sessions alike — in one write:

```bash theme={null}
curl -X PUT "https://orbit.devotel.io/api/v1/recordings/conversation/conv_04H9T2/legal-hold" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hold": true, "reason": "Subpoena 26-4410"}'
```

The aggregate read reports a tally:

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/recordings/conversation/conv_04H9T2/legal-hold" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "conversation_id": "conv_04H9T2",
    "total_recordings": 3,
    "held_recordings": 3
  }
}
```

A conversation that exists with no linked recordings yet returns a 0/0 tally rather than a 404, so you can place holds before the first recording finalizes.

<Warning>
  Preservation is your control, not ours — and this page is **not legal
  advice.** Whether a recording must be preserved, for how long, and who must
  attest to it are your organization's calls. Confirm the specifics with
  qualified counsel.
</Warning>

## 3. Transcript export as VTT or SRT captions

Download a completed recording's transcript as a caption file for review tooling, player subtitles, or eDiscovery bundles:

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/transcript/export?format=vtt" \
  -H "Authorization: Bearer $ORBIT_API_KEY" -o transcript.vtt
```

`format` accepts `vtt` (WebVTT, the default) or `srt` (SubRip). Export requires a finalized recording with a transcript — the API returns `RECORDING_TRANSCRIPT_NOT_COMPLETED` or `RECORDING_TRANSCRIPT_NOT_AVAILABLE` otherwise.

### Pseudonymized export for eDiscovery

For a PII-safe pull, opt in with `pseudonymize=true`. Segments are scrubbed in memory before rendering — email, phone, and long-ID patterns become `[EMAIL]`, `[PHONE]`, and `[ID]` tokens — while timestamps and speaker labels stay accurate. The response tells you what was scrubbed in the `X-Pseudonymized-Count` header:

```bash theme={null}
curl -i "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/transcript/export?format=srt&pseudonymize=true" \
  -H "Authorization: Bearer $ORBIT_API_KEY" -o transcript-redacted.srt
```

```
HTTP/2 200
Content-Type: application/x-subrip; charset=utf-8
Content-Disposition: attachment; filename="transcript-pseudonymized-rec_01J8ZC3NPA.srt"
X-Pseudonymized-Count: email=2;phone=1
```

Raw output is the default; pseudonymization is strictly opt-in, so an export pipeline picks it explicitly per pull. Any value other than the literal `pseudonymize=true` is rejected with a 400. For a video (rather than purely textual) redaction layer, see the [recording redaction concept page](/voice/recording-library).

## 4. Integrity seals: proving a recording has not changed

The integrity surface anchors a recording's content into a tamper-evident hash chain and stamps the encryption posture of your key management setup. Seal once, then verify on demand and ship a signed digest with any exported media.

### Seal a recording

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/integrity/seal" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The body is optional: without a `content_sha256`, the seal uses the content hash the egress pipeline stamped at finalize. Pass `content_sha256` (64-char hex) only if you hash the bytes yourself, and `encryption_algorithm` to record the at-rest algorithm. Sealing requires `voice:write` plus an owner or admin role, and is audit-logged. The response returns the seal:

```json theme={null}
{
  "data": {
    "recording_id": "rec_01J8ZC3NPA",
    "sealed": true,
    "seal": {
      "content_sha256": "0abc…​f9",
      "prev_hash": "71fe…",
      "current_hash": "9d2c…",
      "canonicalization_version": 1,
      "kms_provider": "aws-kms",
      "kms_key_fingerprint": "arn:aws:kms:…​/key/abcd",
      "encryption_algorithm": "AES-256-GCM",
      "sealed_by": "user_2Wq8",
      "sealed_at": "2026-08-29T14:20:01Z"
    }
  }
}
```

Each seal links (`prev_hash`) into your tenant's recording hash chain, so an auditor walking the chain can confirm nothing was inserted, removed, or reordered. If the recording has no content hash yet — finalize has not stamped one and you did not pass one — sealing fails with `RECORDING_INTEGRITY_MISSING_CONTENT_HASH`; pass the hash or wait for finalize.

### Check state and verify

```bash theme={null}
# Current seal state (sealed: false when never sealed)
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/integrity" \
  -H "Authorization: Bearer $ORBIT_API_KEY"

# Re-hash the seal and report tamper status
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/integrity/verify" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

A verify response reports `valid: true|false`, a `reason` on failure, and the `current_hash` versus the recomputed `expected_hash`.

### Ship the signed export digest to an auditor

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/integrity/export-digest" \
  -H "Authorization: Bearer $ORBIT_API_KEY" -o digest.json
```

The digest is a portable, HMAC-signed bundle (recording ID, seal record, jurisdiction, issue time) that an auditor verifies offline against the exported media — seal it before exporting, or the endpoint returns 400. Ship `digest.json` alongside the media file; the auditor does not need API access.

## 5. Clips and auto-detected highlights

Two ways to cut a recording down to the moments that matter: manual clips where you name the range, and automatic highlight detection that proposes ranges from the transcript for an operator to promote or dismiss.

### Manual clips

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/clips" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "Disclosure language", "start_ms": 61200, "end_ms": 74400}'
```

The response (201) returns the clip with a `media_fragment` like `#t=61.2,74.4` — a standard media-fragment seek string your player can append to the recording URL to jump straight to the clip window. List with `GET /recordings/:id/clips`, remove with `DELETE /recordings/:id/clips/:clipId`. Creating and removing clips is audit-logged, and a recording carries a bounded number of clips (the API returns `RECORDING_CLIP_LIMIT_REACHED` at the cap).

### Auto-detected highlights

Detection scans the transcript and proposes moments — questions, action items, sentiment peaks, and speaker handoffs — for review instead of scrubbing the whole timeline by hand:

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/highlights/detect" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

Detection is idempotent — re-running replaces the proposal set. Each moment carries `kind`, `start_ms`/`end_ms`, a `score`, the detection `reason`, and its own `media_fragment`. Detection needs a completed, transcribed recording and returns `RECORDING_HIGHLIGHT_NOT_TRANSCRIBED` when no transcript exists.

Review the proposals with `GET /recordings/:id/highlights`, then promote the moments worth keeping into real clips, optionally renaming them:

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/highlights/mom_7d2/clip" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "Pricing question"}'
```

Dismiss the noise:

```bash theme={null}
curl -X DELETE "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/highlights/mom_9k1" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

Promoted moments land on the same clip list as manual clips, so downstream review tooling sees one uniform surface.

## 6. Share tokens for external auditors

For video-room recordings, you can mint a time-limited public link a guest opens without an Orbit account — the standard way to hand an external auditor or counsel playback access:

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/share" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expires_in_seconds": 604800}'
```

The response returns a `share_url`, the bearer `token`, and `expires_at`. Lifetimes clamp between 5 minutes and 30 days; omit `expires_in_seconds` for the 7-day default. These endpoints sit under the `video:write` scope and only completed video-room recordings are shareable (`RECORDING_SHARE_NOT_SHAREABLE` otherwise).

Revoke every outstanding link at once:

```bash theme={null}
curl -X DELETE "https://orbit.devotel.io/api/v1/recordings/rec_01J8ZC3NPA/share" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

Revocation invalidates prior links immediately; new links can be minted afterwards. Minting and revoking are audit-logged. Public playback still registers in view analytics, so you can confirm the auditor actually watched before closing the matter.

## Troubleshooting

**QC stays `pending` forever.** QC runs when the recording finalizes and the finalize hook passes the row through the scorer. A stuck `pending` usually means the finalize never completed — check the recording's status in the [recording library](/voice/recording-library) first. If the row is finalized, re-run with `POST /recordings/:id/qc/run`; the request is safe to repeat because a passed verdict emits no webhook.

**Legal hold versus retention sweep ordering.** Place holds as soon as a matter is anticipated. Holds exempt recordings from age-based sweeps from the moment they stand, but they cannot resurrect a recording a sweep already deleted before the hold existed. Conversation-scoped GET returning 0/0 is fine — hold it anyway; the batch write re-checks parent links per row and skips tombstoned rows.

**Pseudonymize is rejected or the header is missing.** Only the literal `pseudonymize=true` opts in — `1`, `yes`, or `on` return 400 with `RECORDING_TRANSCRIPT_INVALID_FORMAT`. The `X-Pseudonymized-Count` header appears only on a pseudonymized pull; a raw export never sets it. If your gateway strips headers, check `Content-Disposition` for the `transcript-pseudonymized-` filename prefix as a fallback signal.

**Sealing fails with a missing content hash.** The egress pipeline stamps the content hash at finalize; sealing before finalize (or after a failed finalize) returns `RECORDING_INTEGRITY_MISSING_CONTENT_HASH`. Wait for finalize, or compute the SHA-256 of the media yourself and pass it as `content_sha256`.

**Verify reports the seal is invalid.** Treat a failed `verify` as a genuine tamper signal: the stored seal no longer matches the recomputed hash. Quarantine the recording and investigate before exporting; do not attach a failed-verify recording to an eDiscovery bundle.

**Clip creation fails with a range error.** `end_ms` must exceed `start_ms`, both must land inside the recording's duration, and a clip must clear the minimum duration. `RECORDING_CLIP_INVALID_RANGE` returns the specific reason in the error details.
