Recordings
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Call-recording evidence: retention-locked metadata, signed playback, legal hold, bulk export, per-tenant policy.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/recordings | List call recordings (filtered, keyset-paginated) |
POST | /v1/recording-exports | Queue a bulk recording export |
GET | /v1/recording-exports/{job_id} | Poll a recording export job |
GET | /v1/recordings/{id} | Fetch full recording metadata |
POST | /v1/recordings/{id}/playback-url | Mint a short-lived signed playback URL |
PUT | /v1/recordings/{id}/legal-hold | Set or lift the legal hold on a recording |
GET | /v1/recording-policy | Fetch the tenant recording policy |
PUT | /v1/recording-policy | Replace the tenant recording policy |
List call recordings (filtered, keyset-paginated)
GET /v1/recordingsRecording metadata for the tenant's calls, newest first. The AUDIO is never served here — fetch a short-lived signed URL via POST /v1/recordings/{id}/playback-url. q is a free-text match over the masked phone number, account_ref and any external reference. from/to bound started_at. Foreign/malformed filter ids are indistinguishable from "no match" (RLS), never a leak.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
agent_id | query | string (uuid) | no | |
campaign_id | query | string (uuid) | no | |
disposition | query | string | no | Exact match against an OPEN vocabulary: call_attempts.disposition is written VERBATIM (20260725000003_create_disposition_engine.exs) and the seven engine values — answered_human, abandoned, busy, no_answer, canceled, rejected, failed — are a NAMED SUBSET of it, not its boundary: typed agent/AI outcomes (promise_to_pay, wrong_number) and platform markers (not_placed) are equally valid. An unknown code answers 200 with zero rows, never 400. What IS 400 is a malformed SHAPE: the empty string, and the parameter repeated or sent in array form. |
q | query | string | no | Free-text search over the masked phone number, account_ref and any external reference. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
A page of recordings.
| Field | Type | Required | Description |
|---|---|---|---|
recordings | array of RecordingListItem | yes | |
recordings[].id | string (uuid) | yes | |
recordings[].call_id | string | yes | The call's FreeSWITCH uuid (= call_attempts.call_uuid). |
recordings[].started_at | string (date-time) | yes | UTC. |
recordings[].local_tz | string | null | no | The recipient's IANA zone (e.g. America/Chicago); the UI renders the local time from it. |
recordings[].agent | object | null | no | Denormalised for display; null for AI/inbound-unattended calls. |
recordings[].account_ref | string | null | no | Debt external reference / account number. |
recordings[].phone_number | string | yes | The other party's E.164. |
recordings[].duration_secs | integer | null | no | |
recordings[].disposition | string | null | no | The typification of the recorded call, DERIVED from its terminal CDR attempt (the latest compensating correction, or the original when none landed). null when the call has no resolvable CDR row. |
recordings[].two_party_consent_state | string | null | no | Set when the recipient's state requires all-party consent; the value is the state code the flag was raised for (e.g. WA). null = not applicable. |
recordings[].attest_a | boolean | no | The outbound leg carried STIR/SHAKEN attestation A. |
recordings[].ai_used | boolean | yes | |
recordings[].retained_until | string (date) | no | Retention floor date, frozen at creation (snapshot doctrine). |
recordings[].legal_hold | boolean | yes | |
next_cursor | string | null | yes | Opaque keyset cursor; null on the last page. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Queue a bulk recording export
POST /v1/recording-exportsEnqueues an asynchronous export of the recordings matching the same filters as GET /v1/recordings. Returns 202 with a job_id; poll GET /v1/recording-exports/{job_id} for the signed archive URL. The export job row is durable evidence of who pulled bulk recordings and is never deleted.
An explicit from/to range wider than the platform limit (recording_export_max_range_days, default 366 days) — or a far-past from with no upper bound — is rejected with 422 range_too_large. An unbounded request (no from) is still accepted and bounded downstream by the 50k-row export cap.
Repeating an identical request inside the dedup window (recording_export_dedup_ttl_seconds, default 900) returns the SAME completed job — with a freshly-signed URL — instead of regenerating the archive, so a retry or double-click answers instantly. Send refresh: true to force a new export. The window is short on purpose: recording media uploads land asynchronously, so a long-lived cache of a past window could omit a late-arriving recording.
One GENERATING export per tenant at a time: while an export is being produced, a further generating request for that tenant is rejected with 429 export_in_progress. Retrying once the in-flight export lands is cheap — an identical repeat then takes the dedup path above and answers from the archive it produced. Handing back an already-produced archive is never gated.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
from | string (date-time) | no | |
to | string (date-time) | no | |
agent_id | string (uuid) | no | |
campaign_id | string (uuid) | no | |
disposition | string | no | |
q | string | no | |
refresh | boolean | no | Bypass the short-lived dedup window and force a fresh export even if an identical one was just produced. |
{
"from": "2026-06-01T00:00:00Z",
"to": "2026-06-30T23:59:59Z",
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99"
}Responses
202
Export accepted and queued.
| Field | Type | Required | Description |
|---|---|---|---|
export | object (RecordingExportJob) | yes | |
export.job_id | string (uuid) | yes | |
export.status | queued · running · done · failed | yes | |
export.url | string | null | no | Signed archive URL; present only when status is done. |
export.expires_at | string (date-time) | null | no | |
export.row_count | integer | null | no | |
export.error | string | null | no | |
export.created_at | string (date-time) | no | |
export.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}422
The requested export range exceeds the platform limit (recording_export_max_range_days, default 366 days). Narrow the from/to window and retry.
{
"error": {
"code": "range_too_large",
"message": "requested range exceeds the 366-day export limit"
}
}429
This tenant already has an export being generated. Only one generating export runs per tenant at a time; retry once it completes (an identical repeat is then served from the produced archive without regenerating). A claim whose request died is released automatically after recording_export_claim_stale_seconds (default 900).
{
"error": {
"code": "export_in_progress",
"message": "an export is already running for this tenant; retry when it completes"
}
}503
The recording export object store is not configured. The metadata export does not require the S3 audio store and degrades to the local store when it is unset; a 503 is returned only when no store at all can accept the archive (never a 500).
{
"error": {
"code": "storage_unavailable",
"message": "recording export storage is not configured"
}
}Poll a recording export job
GET /v1/recording-exports/{job_id}Returns the job status; when done, url is a short-lived signed archive URL (expires_at marks its expiry). Cross-tenant/unknown job_id → 404.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
job_id | path | string (uuid) | yes | Export-job UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The export job.
| Field | Type | Required | Description |
|---|---|---|---|
export | object (RecordingExportJob) | yes | |
export.job_id | string (uuid) | yes | |
export.status | queued · running · done · failed | yes | |
export.url | string | null | no | Signed archive URL; present only when status is done. |
export.expires_at | string (date-time) | null | no | |
export.row_count | integer | null | no | |
export.error | string | null | no | |
export.created_at | string (date-time) | no | |
export.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Fetch full recording metadata
GET /v1/recordings/{id}The complete evidence view: consent basis, the frozen gates_at_dial snapshot, the citable decision_ref into the audit chain, object-store metadata and the legal-hold record. No audio bytes.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The recording.
| Field | Type | Required | Description |
|---|---|---|---|
recording | any | yes |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Mint a short-lived signed playback URL
POST /v1/recordings/{id}/playback-urlReturns a short-lived signed object-store URL (Cloudflare R2 / S3-compatible) the browser fetches directly — the audio NEVER transits the API server. expires_at is the URL's hard expiry (minutes). Each mint is audited.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
A freshly signed URL.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Short-lived signed object-store URL. |
expires_at | string (date-time) | yes |
{
"url": "https://r2.example.com/rec/0e9f8a7b...?X-Amz-Signature=...",
"expires_at": "2026-07-09T18:05:00Z"
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}Set or lift the legal hold on a recording
PUT /v1/recordings/{id}/legal-holdIdempotent full-set of the recording's legal-hold sub-resource. active: true REQUIRES a non-empty reason. A held recording is exempt from every purge until the hold is lifted, even past its retention floor. Tenant-management scope: a dd_ tenant API key only — a ddw_ browser token is 403. Setting the hold is audited (set_by, set_at).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
active | boolean | yes | |
reason | string | no | REQUIRED (non-empty) when active is true; ignored when false. |
{
"active": true,
"reason": "Litigation hold — Apex Recovery v. Doe, matter #2026-0417"
}Responses
200
The recording with the updated hold.
| Field | Type | Required | Description |
|---|---|---|---|
recording | any | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Fetch the tenant recording policy
GET /v1/recording-policyResponses
200
The effective policy (defaults materialised on first read).
| Field | Type | Required | Description |
|---|---|---|---|
recording_policy | object (RecordingPolicy) | yes | |
recording_policy.enabled | boolean | yes | Whether new calls are recorded by default. |
recording_policy.retention_months | integer | yes | Retention floor in months (default 39, hard floor 39). |
recording_policy.channels | mono · stereo | yes | |
recording_policy.format | wav · opus | yes | Storage codec for NEW recordings. Never re-encodes what is already stored, and changing it never re-dates a retention: both are frozen per recording at creation. opus is the default a new tenant is born with (it divides the retained footprint by ~8); wav is the value to keep for a tenant under a regulatory, contractual or litigation obligation to hold uncompressed audio, and it carries a surcharge — see RecordingPolicyWrite.format. A tenant already on wav stays on wav: migration 20260907000100 first materialises a wav policy row for every tenant that exists on 2026-09-07, so the new default reaches only tenants created from that date on. Without that step it would also reach every older tenant that had never called this endpoint, because its row is created lazily. |
recording_policy.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Replace the tenant recording policy
PUT /v1/recording-policyFull-set of the singleton per-tenant policy. retention_months is clamped to the regulatory FLOOR (39) — a lower value is 422 unprocessable. Changing the policy NEVER re-dates existing recordings: retained_until is frozen per row at creation (snapshot-at-the-moment doctrine). Tenant-management scope (dd_ only; ddw_ → 403).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | no | |
retention_months | integer | no | Below the floor (39) → 422 unprocessable. |
channels | mono · stereo | no | |
format | wav · opus | no | Storage codec for NEW recordings; outside the vocabulary → 400 bad_request naming the field (the 422 rung is reserved for the retention floor, which is a business-rule refusal). opus is the default and the cheap path. wav is the per-tenant exception in two cases and only two — a regulatory, contractual or litigation obligation to hold uncompressed audio, or the customer asking for it expressly — and it will be billed: the price is settled (DialerDigital/macro-tasks#475, 2026-09-06) at a flat 5.00 USD per seat and month while a tenant's format is wav, counted at the last instant of the closed month, with no exemption for the tenant under a legal obligation (founder's codec decision in freeswitch ADR.md #15, 2026-09-04). The wav_surcharge line IS emitted: the monthly close bills it since DialerDigital/core#779 (ADR #81), on the codec the tenant had at the period boundary — read from the append-only format history, not from the policy row as it stands today — and the same billable seats the seats line uses. Switching format affects only recordings made AFTER the change. Nothing stored is transcoded or re-dated: recordings sit under Object Lock with a 39-month retention floor frozen per row. This enum is mirrored by Recordings.Policy.writable_formats/0 and may legitimately be NARROWER than the read side's: admitting a codec on READ is a catalog fact, minting one on WRITE is a product position. It was [wav] until DialerDigital/core#731 and widened in one change with writable_formats/0 and the column DEFAULT, because a default nobody can also SET is a half-open gate. The FreeSWITCH chart's recording.format is NOT this axis and does not choose the codec: it is per RELEASE while this is per TENANT, and its guard still rejects any value but wav (deploy/chart/templates/_helpers.tpl). What lands on disk is the extension of the object key core hands uuid_record (Recordings.object_key/4), derived from this field. HISTORY, so retired reasons are not resurrected. (a) Until DialerDigital/freeswitch#85 this enum was justified by the upload sidecar resolving a SINGLE extension — a tenant on Opus would have FreeSWITCH write .opus, the sidecar find zero files, and nothing turn red. THAT IS FIXED: the sidecar discovers the whole handled set and quarantines anything outside it, naming it (freeswitch ADR.md #16). Deliberately no line numbers here: the previous text pointed at uploader.sh:94, which that very fix left blank. (b) Do NOT re-add "voice-ai cannot read non-WAV": measured and refuted (DialerDigital/voice-ai#70, closed not-planned 2026-09-04) — voice-ai consumes raw L16 over the media fork and never reads a stored recording, so the storage codec and what voice-ai ingests are independent variables. |
{
"enabled": true,
"retention_months": 39,
"channels": "stereo"
}Responses
200
The updated policy.
| Field | Type | Required | Description |
|---|---|---|---|
recording_policy | object (RecordingPolicy) | yes | |
recording_policy.enabled | boolean | yes | Whether new calls are recorded by default. |
recording_policy.retention_months | integer | yes | Retention floor in months (default 39, hard floor 39). |
recording_policy.channels | mono · stereo | yes | |
recording_policy.format | wav · opus | yes | Storage codec for NEW recordings. Never re-encodes what is already stored, and changing it never re-dates a retention: both are frozen per recording at creation. opus is the default a new tenant is born with (it divides the retained footprint by ~8); wav is the value to keep for a tenant under a regulatory, contractual or litigation obligation to hold uncompressed audio, and it carries a surcharge — see RecordingPolicyWrite.format. A tenant already on wav stays on wav: migration 20260907000100 first materialises a wav policy row for every tenant that exists on 2026-09-07, so the new default reaches only tenants created from that date on. Without that step it would also reach every older tenant that had never called this endpoint, because its row is created lazily. |
recording_policy.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}