Skip to content

Recordings

Generated from DialerDigital/core commit 6e9f026f0a72 — spec openapi/dialer-v1.yaml v0.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

MethodPathSummary
GET/v1/recordingsList call recordings (filtered, keyset-paginated)
POST/v1/recording-exportsQueue 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-urlMint a short-lived signed playback URL
PUT/v1/recordings/{id}/legal-holdSet or lift the legal hold on a recording
GET/v1/recording-policyFetch the tenant recording policy
PUT/v1/recording-policyReplace the tenant recording policy

List call recordings (filtered, keyset-paginated)

http
GET /v1/recordings

Recording 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

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
agent_idquerystring (uuid)no
campaign_idquerystring (uuid)no
dispositionquerystringnoExact 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.
qquerystringnoFree-text search over the masked phone number, account_ref and any external reference.
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200

A page of recordings.

FieldTypeRequiredDescription
recordingsarray of RecordingListItemyes
recordings[].idstring (uuid)yes
recordings[].call_idstringyesThe call's FreeSWITCH uuid (= call_attempts.call_uuid).
recordings[].started_atstring (date-time)yesUTC.
recordings[].local_tzstring | nullnoThe recipient's IANA zone (e.g. America/Chicago); the UI renders the local time from it.
recordings[].agentobject | nullnoDenormalised for display; null for AI/inbound-unattended calls.
recordings[].account_refstring | nullnoDebt external reference / account number.
recordings[].phone_numberstringyesThe other party's E.164.
recordings[].duration_secsinteger | nullno
recordings[].dispositionstring | nullnoThe 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_statestring | nullnoSet 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_abooleannoThe outbound leg carried STIR/SHAKEN attestation A.
recordings[].ai_usedbooleanyes
recordings[].retained_untilstring (date)noRetention floor date, frozen at creation (snapshot doctrine).
recordings[].legal_holdbooleanyes
next_cursorstring | nullyesOpaque keyset cursor; null on the last page.

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

Queue a bulk recording export

http
POST /v1/recording-exports

Enqueues 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)

FieldTypeRequiredDescription
fromstring (date-time)no
tostring (date-time)no
agent_idstring (uuid)no
campaign_idstring (uuid)no
dispositionstringno
qstringno
refreshbooleannoBypass the short-lived dedup window and force a fresh export even if an identical one was just produced.
json
{
  "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.

FieldTypeRequiredDescription
exportobject (RecordingExportJob)yes
export.job_idstring (uuid)yes
export.statusqueued · running · done · failedyes
export.urlstring | nullnoSigned archive URL; present only when status is done.
export.expires_atstring (date-time) | nullno
export.row_countinteger | nullno
export.errorstring | nullno
export.created_atstring (date-time)no
export.updated_atstring (date-time)no

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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).

json
{
  "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.

json
{
  "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).

json
{
  "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).

json
{
  "error": {
    "code": "storage_unavailable",
    "message": "recording export storage is not configured"
  }
}

Poll a recording export job

http
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_id404.

Parameters

NameInTypeRequiredDescription
job_idpathstring (uuid)yesExport-job UUID. Malformed or cross-tenant ids read as 404.

Responses

200

The export job.

FieldTypeRequiredDescription
exportobject (RecordingExportJob)yes
export.job_idstring (uuid)yes
export.statusqueued · running · done · failedyes
export.urlstring | nullnoSigned archive URL; present only when status is done.
export.expires_atstring (date-time) | nullno
export.row_countinteger | nullno
export.errorstring | nullno
export.created_atstring (date-time)no
export.updated_atstring (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).

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

Fetch full recording metadata

http
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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200

The recording.

FieldTypeRequiredDescription
recordinganyyes

401 — Unauthorized

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

Mint a short-lived signed playback URL

http
POST /v1/recordings/{id}/playback-url

Returns 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Responses

200

A freshly signed URL.

FieldTypeRequiredDescription
urlstringyesShort-lived signed object-store URL.
expires_atstring (date-time)yes
json
{
  "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).

json
{
  "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).

json
{
  "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.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

Set or lift the legal hold on a recording

http
PUT /v1/recordings/{id}/legal-hold

Idempotent 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.

Request body (JSON, required)

FieldTypeRequiredDescription
activebooleanyes
reasonstringnoREQUIRED (non-empty) when active is true; ignored when false.
json
{
  "active": true,
  "reason": "Litigation hold — Apex Recovery v. Doe, matter #2026-0417"
}

Responses

200

The recording with the updated hold.

FieldTypeRequiredDescription
recordinganyyes

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "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).

json
{
  "error": {
    "code": "not_found",
    "message": "resource not found for this tenant"
  }
}

Fetch the tenant recording policy

http
GET /v1/recording-policy

Responses

200

The effective policy (defaults materialised on first read).

FieldTypeRequiredDescription
recording_policyobject (RecordingPolicy)yes
recording_policy.enabledbooleanyesWhether new calls are recorded by default.
recording_policy.retention_monthsintegeryesRetention floor in months (default 39, hard floor 39).
recording_policy.channelsmono · stereoyes
recording_policy.formatwav · opusyesStorage 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_atstring (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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

Replace the tenant recording policy

http
PUT /v1/recording-policy

Full-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)

FieldTypeRequiredDescription
enabledbooleanno
retention_monthsintegernoBelow the floor (39) → 422 unprocessable.
channelsmono · stereono
formatwav · opusnoStorage 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.
json
{
  "enabled": true,
  "retention_months": 39,
  "channels": "stereo"
}

Responses

200

The updated policy.

FieldTypeRequiredDescription
recording_policyobject (RecordingPolicy)yes
recording_policy.enabledbooleanyesWhether new calls are recorded by default.
recording_policy.retention_monthsintegeryesRetention floor in months (default 39, hard floor 39).
recording_policy.channelsmono · stereoyes
recording_policy.formatwav · opusyesStorage 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_atstring (date-time)no

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

422 — Unprocessable

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at must be at least 10 minutes out"
  }
}

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.