Skip to content

Recordings ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

Call-recording evidence: retention-locked metadata, signed playback, legal hold, bulk export, per-tenant policy.

Endpoints ​

MethodPathSummary
PUT/v1/screen-recordings/{id}/chunks/{n}Upload one chunk of the OWN seat's screen recording
POST/v1/screen-recordings/gapsDeclare that a call's screen is missing, and why
POST/v1/screen-recordings/{id}/pii-marksDeclare which stretches of the OWN seat's screen recording showed PII
POST/v1/screen-recordings/playbackAsk to watch the REDACTED screen recording of a call; returns a short-lived signed URL
POST/v1/screen-recordings/original-playbackAsk to watch the UNREDACTED screen recording of a call, with a reason; returns a short-lived signed URL
GET/v1/recordingsList call recordings (filtered, keyset-paginated)
POST/v1/privacy/subject-exportsExport the data held about one person
POST/v1/privacy/erasure-requestsAsk what erasing one person would remove and what it must keep
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
POST/v1/recordings/{id}/original-urlMint a short-lived URL for the UNREDACTED audio
GET/v1/recordings/{id}/redactionThe PII redaction of a recording's audio
POST/v1/recordings/{id}/redactionRedact PII spans of a recording's audio
GET/v1/recordings/{id}/transcriptThe transcript of a recording
POST/v1/recordings/{id}/transcript/originalThe UNREDACTED transcript of a recording
GET/v1/transcripts/searchSearch a word or phrase in the transcripts of a window
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
GET/v1/business-typesRead the tenant's declared business types and the recording-retention floor they imply
PUT/v1/business-typesDeclare the tenant's business types

Upload one chunk of the OWN seat's screen recording ​

http
PUT /v1/screen-recordings/{id}/chunks/{n}

Stores chunk n (from 0) of the screen recording id of one call. The browser's MediaRecorder sends the chunks as they are produced; id is a UUID the client chooses for the whole recording, so a retry is idempotent.

Everything that identifies the recording comes from core, not from the client: the seat from the bearer, the call from call_uuid (which must be a call of THIS seat) and the retention from the voice recording of the same call (retained_until). The start and end instants are those of the call and are not stored again.

The chunk is sealed with the tenant's key (AES-256-GCM) before it reaches the store, bound to id and n: the object cannot be opened elsewhere.

Gates, in order: only a ddu_ login with a linked seat (403 for a dd_ key, a ddu_ with no seat, and a ddw_ token); 404 not_found while the tenant's flag screen_monitor_enabled is off; 404 not_found for a call_uuid that is unknown or is another agent's call (the same answer, and an audit row records which); 403 screen_recording_not_acknowledged when the seat has no current screen_recording acknowledgement (nothing is stored and a not_armed gap is recorded).

Repeating a chunk with the same bytes answers 200 duplicate; other bytes under the same n are 409 chunk_conflict; a second recording id for the same call is 409 recording_conflict. A chunk the store did not take is 503 and records an upload_failed gap.

Limits: 4 000 000 bytes per chunk, n from 0 to 1 000 000.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesRecording id, chosen by the client, one per call.
npathintegeryesChunk number, from 0.
call_uuidquerystringyesThe call being recorded; it must be the caller's own.

Responses

200 ​

The chunk was already stored with the same bytes.

FieldTypeRequiredDescription
chunkobjectyes
chunk.recording_idstring (uuid)yes
chunk.seqintegeryes
chunk.bytesintegeryesSize of the chunk before sealing.
chunk.statusstored · duplicateyes
chunk.retained_untilstring (date)yesInherited from the voice recording of the same call.

201 ​

The chunk was sealed and stored.

FieldTypeRequiredDescription
chunkobjectyes
chunk.recording_idstring (uuid)yes
chunk.seqintegeryes
chunk.bytesintegeryesSize of the chunk before sealing.
chunk.statusstored · duplicateyes
chunk.retained_untilstring (date)yesInherited from the voice recording of the same call.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

409 ​

chunk_conflict: that n already holds other bytes. recording_conflict: the call already has a recording under another id, or id belongs to another call.

json
{
  "error": {
    "code": "chunk_conflict",
    "message": "that chunk number already holds other bytes"
  }
}

410 ​

The screen recording was purged at the end of its retention.

413 ​

The chunk is larger than 4 000 000 bytes.

415 ​

The body is not video/webm or video/mp4.

503 ​

The store did not take the chunk; retry it.

Declare that a call's screen is missing, and why ​

http
POST /v1/screen-recordings/gaps

The agent's client reports a gap it knows about: agent_stopped (the agent ended the capture), upload_failed (the chunks did not arrive) or not_armed (the capture was never started). Core writes its own not_armed gap when a chunk arrives without a current acknowledgement.

Idempotent: one row per call, reason and source. The ledger is append-only. The same seat and call rules as the chunk upload apply (404 not_found for a call that is not the caller's; 404 while screen_monitor_enabled is off). No acknowledgement is needed: a gap is evidence, not content.

Request body (JSON, required)

FieldTypeRequiredDescription
call_uuidstringyes
reasonnot_armed · agent_stopped · upload_failedyes
notestringno

Responses

200 ​

The gap is on file (the first one when repeated).

FieldTypeRequiredDescription
gapobjectyes
gap.call_uuidstringyes
gap.reasonnot_armed · agent_stopped · upload_failedyes
gap.sourceagentyes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

Declare which stretches of the OWN seat's screen recording showed PII ​

http
POST /v1/screen-recordings/{id}/pii-marks

The agent's dashboard declares the intervals during which the screen showed a surface it paints as PII, and how far it has classified the video (covered_through_ms). Core keeps them append-only next to the recording; the redacted copy that supervisors watch by default is built from them (core#2017, ADR #266): every mark, widened by a guard on both sides, and everything after covered_through_ms is blacked out. A recording with no coverage row is blacked out entirely, so a client that never reports leaves a black copy, never a clear one.

zone is a CLOSED catalogue: consumer_card (the consumer's record), full_number (the phone number unmasked), payment (card, bank account, SSN, PIN) and focus_lost (the dashboard has no focus or is not visible, so it does not know what the screen shows). A zone outside the catalogue is 422 invalid_zone; it is never stored as free text. A mark carries instants only, never text or values.

Instants are milliseconds from the start of the recording's video, 0 <= start_ms < end_ms <= 86 400 000. One request takes at most 200 marks and one recording holds at most 1 000 (422 too_many_marks, 422 mark_limit_reached; a refused request stores nothing). marks may be empty: a request with only covered_through_ms is a heartbeat.

Idempotent: a mark already on file (same start_ms, end_ms, zone) is counted in duplicates and not stored twice. covered_through_ms only moves forward: a lower value than the one on file is ignored, not an error (it is a retry that arrived late), and the response says the value in force.

Same gates as the chunk upload, in order: only a ddu_ login with a linked seat (403 for a dd_ key, a ddu_ with no seat, and a ddw_ token); 404 not_found while the tenant's flag screen_monitor_enabled is off; 404 not_found for a recording that is unknown or belongs to another seat or tenant (one answer for all three; the recording exists once its first chunk landed, so a client that is ahead of its upload gets 404 and retries); 403 screen_recording_not_acknowledged when the seat has no current screen_recording acknowledgement; 410 once the recording was purged.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesThe recording id the client chose for the chunk upload.

Request body (JSON, required)

FieldTypeRequiredDescription
marksarray of objectno
marks[].start_msintegeryes
marks[].end_msintegeryes
marks[].zoneconsumer_card · full_number · payment · focus_lostyes
covered_through_msintegeryes

Responses

200 ​

The marks are on file and the coverage is in force.

FieldTypeRequiredDescription
pii_marksobjectyes
pii_marks.recording_idstring (uuid)yes
pii_marks.storedintegeryesMarks written by this request.
pii_marks.duplicatesintegeryesMarks already on file.
pii_marks.covered_through_msintegeryesThe value in force, the larger of the one on file and the one sent.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

410 ​

The screen recording was purged at the end of its retention.

422 ​

invalid_zone: the zone is not in the catalogue. invalid_interval: a mark outside 0 <= start_ms < end_ms <= 86 400 000. too_many_marks: more than 200 in one request. mark_limit_reached: the recording would hold more than 1 000.

json
{
  "error": {
    "code": "invalid_zone",
    "message": "zone must be one of: consumer_card, full_number, payment, focus_lost"
  }
}

Ask to watch the REDACTED screen recording of a call; returns a short-lived signed URL ​

http
POST /v1/screen-recordings/playback

Takes a supervisor or admin seat (403 role_forbidden otherwise; a dd_ key has no seat, so 403 forbidden). It does NOT consult screen_monitor_enabled: switching the function off does not hide what was already recorded.

What is served is the REDACTED copy: the video with every stretch the dashboard marked as PII (and every stretch it never classified) blacked out (core#2124, ADR #266). It is served only once the copy is ready. While it is being built, or rebuilt after a newer mark arrived, the answer is 409 redaction_pending; if it could not be built, 409 redaction_failed. The original is NEVER served in its place: the unmasked video is a separate route, POST /v1/screen-recordings/original-playback.

The access row (who, which call_uuid, which variant, until when) is written in the same transaction BEFORE the URL is minted; with no row there is no URL. The chunks are stored as ciphertext, so the URL is not a store URL: it points at GET /v1/screen-media/{token}, which needs NO bearer (the signed token in the path is the credential, valid for expires_at, 5 minutes) and so can be the src of a <video>. That route streams the decrypted chunks in order with the stored content type, Cache-Control: no-store; a token that is forged, expired or not backed by an access row is 404, a recording purged since is 410, and a token minted for the redacted copy whose copy stopped being servable since (a newer mark is being rebuilt) is 409, never the original. It is not part of the public widget surface.

gaps lists why the screen may be incomplete (not_armed, agent_stopped, upload_failed). A call with no recording is 404 (the gaps of a call that never got a chunk are not returned here); a purged one is 410 and writes no access row.

Request body (JSON, required)

FieldTypeRequiredDescription
call_uuidstringyes

Responses

200 ​

The access is on file and the URL is ready.

FieldTypeRequiredDescription
playbackobjectyes
playback.recording_idstring (uuid)yes
playback.call_uuidstringyes
playback.variantredactedyesWhich video the URL serves.
playback.urlstringyesPath under the API origin: /v1/screen-media/{token}.
playback.expires_atstring (date-time)yes
playback.retained_untilstring (date)yes
playback.chunksintegeryes
playback.content_typevideo/webm · video/mp4yes
playback.gapsarray of objectyes
playback.gaps[].reasonnot_armed · agent_stopped · upload_failedyes
playback.gaps[].sourceserver · agentyes
playback.gaps[].notestring,nullyes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

409 ​

redaction_pending: the redacted copy is not ready yet (or is being rebuilt). redaction_failed: it could not be built. No access row is written and no URL is minted; the original is not served instead.

json
{
  "error": {
    "code": "redaction_pending",
    "message": "the redacted copy is not ready yet; the original is never served in its place"
  }
}

410 ​

The recording was purged.

Ask to watch the UNREDACTED screen recording of a call, with a reason; returns a short-lived signed URL ​

http
POST /v1/screen-recordings/original-playback

The unmasked video, which can show a consumer's card or full number (core#2124, ADR #266). It takes a dashboard login (ddu_) with the account_admin role and nothing else: a member, a supervisor without that role, a dd_ API key and any other principal get 403 role_forbidden. It needs no agent seat and, like the redacted route, does not consult screen_monitor_enabled.

reason is mandatory (a non-empty string of at most 500 characters, else 400). The access row (the actor user:<id>, the reason, variant: original, the call_uuid and when it expires) is written in the same transaction BEFORE the URL is minted; with no row there is no URL. The URL is the same kind as the redacted one (GET /v1/screen-media/{token}, no bearer, 5 minutes, Cache-Control: no-store), but the access row it points at says original, so it streams the original chunks. A call with no recording is 404; a purged one is 410 and writes no access row. The audio counterpart is POST /v1/recordings/{id}/original-url.

Request body (JSON, required)

FieldTypeRequiredDescription
call_uuidstringyes
reasonstringyesWhy the original is needed; stored with the access.

Responses

200 ​

The access is on file and the URL is ready.

FieldTypeRequiredDescription
playbackobjectyes
playback.recording_idstring (uuid)yes
playback.call_uuidstringyes
playback.variantoriginalyes
playback.urlstringyesPath under the API origin: /v1/screen-media/{token}.
playback.expires_atstring (date-time)yes
playback.retained_untilstring (date)yes
playback.chunksintegeryes
playback.content_typevideo/webm · video/mp4yes
playback.gapsarray of objectyes
playback.gaps[].reasonnot_armed · agent_stopped · upload_failedyes
playback.gaps[].sourceserver · agentyes
playback.gaps[].notestring,nullyes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

410 ​

The recording was purged.

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 engine values — answered_human, abandoned, delivered, ai_conversation, answered_machine, ai_stream_lost, 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
recordings[].statuspending · available · purged · lostnoWhere the audio is. pending = the call is recorded and the audio is not uploaded yet; available = uploaded and playable; purged = deleted at the end of its retention (the row survives as evidence); lost = the call ended, no upload ever confirmed and the object store holds no audio (for example the media pod died before the sidecar uploaded it). A lost row has no playback (409) and carries lost_at and lost_reason.
recordings[].lost_atstring (date-time) | nullnoWhen the row was closed as lost. Kept if the audio arrived late and the row returned to available.
recordings[].lost_reasonno_upload_after_call_end · no_upload_call_never_ended | nullnoWhy it was closed as lost; null when it never was.
recordings[].composed_from_partsbooleannotrue when the audio was composed from the chunks the media pod uploaded WHILE the call lasted, because the whole file never arrived (for example the pod died mid-call). The row is available and playable, but the audio is TRUNCATED: read audio_gap_ms. false for a recording whose file arrived whole.
recordings[].audio_gap_msinteger (int64) | nullnoThe declared gap of a composed_from_parts recording, in milliseconds: the call duration per the CDR minus the audio actually composed (it includes the second or two the media server takes to start writing). null when the recording is not composed from parts, or the call never recorded an end and the gap cannot be computed.
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Export the data held about one person ​

http
POST /v1/privacy/subject-exports

Gathers, for ONE person given by phone (E.164) or consumer_ref (exactly one), what the platform keeps of them in this tenant, and writes it as a ZIP to the exports bucket (it expires on its own schedule): one JSON per category (debts, contacts, call_attempts, consents, sms_messages, email_messages, ai_conversations, recordings, transcripts) and a manifest.json that names the tables read, the rows per category, the categories cut by the row cap and what is omitted and why (the recording audio is never packaged). The response carries the same manifest and a short-lived signed URL.

A person with NO data gets the same ZIP with every count at zero, never a 404: the route does not reveal whether the person exists.

PII. Redacted by default: phone numbers come out as *** plus the last four digits, free text (SMS, email, AI conversation, transcript) passes through the PII redaction, and account_number and debtor_name are withheld. include_pii: true asks for the unredacted export and is audited like the other raw exports.

Who. A dd_ key or a dashboard login with the account_admin role. A member gets 403 role_forbidden (not a masked answer), with or without include_pii.

Flag. Behind privacy_subject_export_enabled, off by default: 503 subject_export_disabled and nothing is read.

Trail. Each request writes an append-only requested event BEFORE reading and a delivered one when the archive is stored; the person is recorded only as an HMAC. If the request cannot be recorded, nothing is exported (503 audit_unavailable).

Request body (JSON, required)

FieldTypeRequiredDescription
phonestringnoE.164 number.
consumer_refstringno
include_piibooleannoUnredacted export; takes a dd_ key or an account_admin login, audited.
json
{
  "phone": "+13055550123",
  "include_pii": false
}

Responses

202 ​

The export was produced; the archive is at url.

FieldTypeRequiredDescription
subject_exportobject (SubjectExport)yes
subject_export.idstring (uuid)yesThe requested event of the audit trail.
subject_export.statusdoneyes
subject_export.include_piibooleanyes
subject_export.urlstring,nullnoShort-lived signed URL of the ZIP.
subject_export.url_expires_atstring,null (date-time)no
subject_export.manifestobjectyesSame content as manifest.json inside the ZIP.
subject_export.manifest.subjectobjectyes
subject_export.manifest.subject.kindphone · consumer_refno
subject_export.manifest.subject.hashstringnoHMAC of the subject, hex.
subject_export.manifest.include_piibooleanyes
subject_export.manifest.generated_atstring (date-time)no
subject_export.manifest.row_cap_per_categoryintegerno
subject_export.manifest.categoriesobjectyes
subject_export.manifest.truncatedarray of stringyes
subject_export.manifest.omittedarray of objectyes
subject_export.manifest.omitted[].itemstringno
subject_export.manifest.omitted[].reasonstringno

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

503 ​

subject_export_disabled (the flag is off for this tenant), audit_unavailable (the request could not be recorded, nothing was exported) or storage_unavailable (the export store did not take the archive).

json
{
  "error": {
    "code": "subject_export_disabled",
    "message": "subject exports are not enabled for this tenant"
  }
}

Ask what erasing one person would remove and what it must keep ​

http
POST /v1/privacy/erasure-requests

For ONE person given by phone (E.164) or consumer_ref (exactly one), finds what the tenant holds of them (phone by blind index) and classifies every category: what would be anonymized, and what is RETAINED with the reason and the date until which it is kept (the records floor of the tenant, a recording's legal_hold or retention date, suppression kept as a hash). Copies that derive from the rows (the analytic ClickHouse copy, audio under Object Lock) are declared, not hidden.

This version is a dry run. It reads, classifies and records the request; it changes no row. dry_run defaults to true; dry_run: false answers 422 erasure_execution_unavailable.

Suppression survives. DNC listings, cease-and-desist records and revoked consents are never counted as erasable.

Who. A dd_ key or a dashboard login with the account_admin role (403 role_forbidden otherwise).

Flag. Behind privacy_erasure_enabled, off by default and per tenant: 403 privacy_erasure_disabled and nothing is written.

Trail. Each request writes an append-only privacy_requests row (who, when, the person only as an HMAC, the report). If it cannot be recorded, no report is returned (503 audit_unavailable).

Request body (JSON, required)

FieldTypeRequiredDescription
phonestringnoE.164 number.
consumer_refstringno
dry_runbooleannoOnly true is available in this version.
json
{
  "phone": "+13055550123",
  "dry_run": true
}

Responses

202 ​

The report of the dry run.

FieldTypeRequiredDescription
erasure_requestobject (ErasureRequest)yes
erasure_request.idstring (uuid)yesThe privacy_requests row.
erasure_request.dry_runbooleanyes
erasure_request.reportobjectyes
erasure_request.report.subjectobjectyes
erasure_request.report.subject.kindphone · consumer_refno
erasure_request.report.subject.hashstringnoHMAC of the subject, hex.
erasure_request.report.dry_runbooleanyes
erasure_request.report.generated_atstring (date-time)no
erasure_request.report.records_floor_monthsintegeryes
erasure_request.report.floor_basisdeclared · default_max_of_supported_typesno
erasure_request.report.categoriesarray of objectyes
erasure_request.report.categories[].categorystringno
erasure_request.report.categories[].tablesarray of stringno
erasure_request.report.categories[].foundintegerno
erasure_request.report.categories[].would_anonymizeintegerno
erasure_request.report.categories[].actionanonymize · noneno
erasure_request.report.categories[].eventsintegernoAI conversations only: their events.
erasure_request.report.categories[].retainedarray of objectno
erasure_request.report.derived_copiesarray of objectyes
erasure_request.report.derived_copies[].itemstringno
erasure_request.report.derived_copies[].statusstringno
erasure_request.report.derived_copies[].untilstring,nullno
erasure_request.report.derived_copies[].reasonstringno
erasure_request.report.executionnot_availableyes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

422 ​

erasure_execution_unavailable: dry_run: false is not available yet.

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

503 ​

audit_unavailable: the request could not be recorded, so no report was produced.

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, but a selection with more rows than the platform row limit (recording_export_max_rows, default 50 000) is rejected with 422 export_too_large — never cut short: a done export always carries EVERY row that matches. The error carries max_rows and matching_rows; narrow the filters and retry.

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. A cached job whose archive is no longer in the export store is not reused: the request regenerates it.

The archive is written to a dedicated export store, never to the recording store (whose retention would seal it): it carries phone numbers and account references, and the export store expires it. The job row stays; once its archive is gone, GET /v1/recording-exports/{job_id} reports status: expired.

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.

PII (core#2018, ADR #240). The CSV inside the archive is redacted by default: free text such as account_ref that carries a payment card number, an SSN, a bank account number or a PIN/password comes out masked, keeping at most the last four digits. include_pii: true asks for the archive WITHOUT redaction; it takes a dd_ key or a dashboard login with the account_admin role (403 role_forbidden otherwise), is audited, and produces its own archive — a raw request never reuses (or feeds) a redacted one in the dedup window. GET /v1/recording-exports/{job_id} of a raw job answers 403 role_forbidden to anyone else.

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.
include_piibooleannotrue produces the archive WITHOUT PII redaction (ADR #240). Requires a dd_ key or an account_admin login: 403 role_forbidden otherwise. Absent or false ⇒ redacted. Audited.
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 · failed · expiredyesexpired is reported on read for a done job whose archive is no longer in the export store; regenerate with a new export request.
export.piiredacted · rawnoWhether the archive was produced with the PII of the closed ADR #240 catalog redacted (default) or raw (include_pii: true).
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

422 ​

range_too_large: the requested export range exceeds the platform limit (recording_export_max_range_days, default 366 days). Narrow the from/to window and retry.

export_too_large: the filters match more recordings than the platform row limit (recording_export_max_rows, default 50 000). The error adds max_rows and matching_rows; no job is created. Narrow the filters and retry.

range_too_large

json
{
  "error": {
    "code": "range_too_large",
    "message": "requested range exceeds the 366-day export limit"
  }
}

export_too_large

json
{
  "error": {
    "code": "export_too_large",
    "message": "the filters match 120000 recordings, above the 50000-row export limit; narrow the filters and retry",
    "max_rows": 50000,
    "matching_rows": 120000
  }
}

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 export store could not take the archive (unreachable, misconfigured or refusing the write). The export store is its own store, independent of the recording audio store; a 503 is returned whenever it fails, 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_id → 404.

expired: the job completed but its archive is no longer in the export store (the store expires archives on its own schedule). url and expires_at are null; send a new POST /v1/recording-exports to regenerate. The job itself is kept as evidence of who exported what. A job created with include_pii: true (pii: raw) is 403 role_forbidden for a caller without the account_admin role. If the store cannot be reached the job stays done with its signed URL — expired is only reported when the archive is known to be gone.

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 · failed · expiredyesexpired is reported on read for a done job whose archive is no longer in the export store; regenerate with a new export request.
export.piiredacted · rawnoWhether the archive was produced with the PII of the closed ADR #240 catalog redacted (default) or raw (include_pii: true).
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Mint a short-lived URL for the UNREDACTED audio ​

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

The default view (POST /v1/recordings/{id}/playback-url) plays the PII-redacted copy. This is the only way to the original. Requires a dd_ tenant key or a dashboard login with the account_admin role (anything else is 403 role_forbidden) and a non-empty reason (at most 500 characters). The actor and the reason are written to an append-only access log BEFORE the URL is returned.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
reasonstringyes
json
{
  "reason": "Dispute 2026-0417: customer denies the payment promise"
}

Responses

200 ​

A freshly signed URL for the original audio.

FieldTypeRequiredDescription
urlstringyesShort-lived signed object-store URL.
expires_atstring (date-time)yes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

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

The PII redaction of a recording's audio ​

http
GET /v1/recordings/{id}/redaction

Spans (time and category only, never the value), method and version. 404 when the recording has no redaction.

Parameters

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

Responses

200 ​

The redaction.

FieldTypeRequiredDescription
redactionobject (RecordingRedaction)no
redaction.recording_idstring (uuid)no
redaction.methodsilence · beepno
redaction.segmentsarray of RecordingRedactionSegmentno
redaction.segments[].start_msintegeryes
redaction.segments[].end_msintegeryes
redaction.segments[].categorycard · ssn · bank_account · pinyes
redaction.redacted_msintegerno
redaction.versionintegerno
redaction.codecstringno
redaction.requested_bystringno
redaction.created_atstring (date-time)no
redaction.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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

Redact PII spans of a recording's audio ​

http
POST /v1/recordings/{id}/redaction

Same role gate as original-url. The spans are unioned with those already stored and the redacted copy is rebuilt from the original (idempotent, version goes up when something new was added). Only WAV recordings: another format is 422 unsupported_codec.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
methodsilence · beepno
segmentsarray of RecordingRedactionSegmentyes
segments[].start_msintegeryes
segments[].end_msintegeryes
segments[].categorycard · ssn · bank_account · pinyes

Responses

200 ​

The redaction after the call.

FieldTypeRequiredDescription
redactionobject (RecordingRedaction)no
redaction.recording_idstring (uuid)no
redaction.methodsilence · beepno
redaction.segmentsarray of RecordingRedactionSegmentno
redaction.segments[].start_msintegeryes
redaction.segments[].end_msintegeryes
redaction.segments[].categorycard · ssn · bank_account · pinyes
redaction.redacted_msintegerno
redaction.versionintegerno
redaction.codecstringno
redaction.requested_bystringno
redaction.created_atstring (date-time)no
redaction.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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

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

422 — Unprocessable ​

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

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

503 ​

storage_unavailable: the recording store cannot read the original or write the redacted copy right now. Nothing was persisted; retry later.

The transcript of a recording ​

http
GET /v1/recordings/{id}/transcript

The speech-to-text transcript of ONE recording, with its decrypted timed segments ordered by seq. It is raw personal data of a call, so only a dashboard login (ddu_) with a supervisor or admin seat, or an account_admin login without a seat, may read it: a dd_ key, a widget token and an agent seat are 403 role_forbidden. Every delivery appends a read event (who, when, which recording, how many segments; never the text) in the same transaction as the read.

The text is the PII-redacted default view (redacted: true): card numbers, social security numbers, bank accounts and PINs/passwords are replaced by masks or [REDACTED]. The original is only reachable through POST /v1/recordings/{id}/transcript/original.

404 when the recording has no transcript in this tenant: it belongs to another tenant, its audio was purged (the purge deletes the transcript with it), it was not eligible, or the id is malformed. A recording under legal_hold keeps its transcript.

Parameters

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

Responses

200 ​

The transcript.

FieldTypeRequiredDescription
transcriptobject (RecordingTranscript)yes
transcript.recording_idstring (uuid)yes
transcript.redactedbooleanyestrue for the default view (PII redacted); false only on the audited original.
transcript.providerstringyesSpeech-to-text provider that produced it.
transcript.modelstringyes
transcript.languagestring,nullnoLanguage the provider reported, when it reported one.
transcript.audio_secondsintegeryesSeconds of audio billed.
transcript.segment_countintegeryes
transcript.created_atstring (date-time)yes
transcript.segmentsarray of objectyes
transcript.segments[].seqintegeryes
transcript.segments[].channelintegeryesAudio channel: 0 or 1.
transcript.segments[].start_msintegeryes
transcript.segments[].end_msintegeryes
transcript.segments[].textstringyes

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

503 ​

The read event could not be recorded, so no text was returned.

The UNREDACTED transcript of a recording ​

http
POST /v1/recordings/{id}/transcript/original

The default view (GET /v1/recordings/{id}/transcript) has the PII redacted. This is the only way to the original text, kept encrypted for audit (Reg F). Requires a dd_ tenant key or a dashboard login with the account_admin role (anything else is 403 role_forbidden) and a non-empty reason (at most 500 characters). The actor, the reason and the segment count (never the text) are written to an append-only access log BEFORE the text is returned: if the row cannot be written, no text leaves (503). 404 as in the default view.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
reasonstringyes
json
{
  "reason": "Dispute 2026-0417: customer denies the payment promise"
}

Responses

200 ​

The transcript, with redacted: false.

FieldTypeRequiredDescription
transcriptobject (RecordingTranscript)yes
transcript.recording_idstring (uuid)yes
transcript.redactedbooleanyestrue for the default view (PII redacted); false only on the audited original.
transcript.providerstringyesSpeech-to-text provider that produced it.
transcript.modelstringyes
transcript.languagestring,nullnoLanguage the provider reported, when it reported one.
transcript.audio_secondsintegeryesSeconds of audio billed.
transcript.segment_countintegeryes
transcript.created_atstring (date-time)yes
transcript.segmentsarray of objectyes
transcript.segments[].seqintegeryes
transcript.segments[].channelintegeryesAudio channel: 0 or 1.
transcript.segments[].start_msintegeryes
transcript.segments[].end_msintegeryes
transcript.segments[].textstringyes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

503 ​

The access could not be recorded, so no text was returned.

Search a word or phrase in the transcripts of a window ​

http
GET /v1/transcripts/search

Finds the recordings of this tenant whose transcript contains q, newest first. The words of q are looked up in a BLIND index (a keyed hash with the tenant inside: the same word hashes differently in two tenants, and no word is stored in the clear); only the candidate segments of the candidate recordings are decrypted, and a phrase is verified on the decrypted text: the words must appear in a row inside ONE segment. Matching is exact on the normalised word (case, accents and punctuation are ignored; no stemming, no synonyms). A recording whose audio was purged has no transcript and never appears; one under legal_hold does.

from and to are mandatory, from must be before to and the window may not exceed 31 days (400 otherwise); q must contain at least one and at most 8 words. A call examines at most 100 candidate recordings and 500 segments: when that cap stops the search before the page is full, capped is true and next_cursor carries on from where it stopped. It is raw personal data of calls, so the gate is the one of the single transcript: a dashboard login (ddu_) with a supervisor or admin seat, or an account_admin login without a seat; a dd_ key, a widget token and an agent seat are 403 role_forbidden.

Every search that runs appends an event (who, when, the HASH of the query, the window, the filters, how many recordings were examined and returned; never the text) in the same transaction: if it cannot be written there is no result (503).

Parameters

NameInTypeRequiredDescription
qquerystringyesThe word or phrase (1 to 8 words).
fromquerystring (date-time)yesRFC 3339 lower bound on the recording start (inclusive).
toquerystring (date-time)yesRFC 3339 upper bound on the recording start; at most 31 days after from.
agent_idquerystring (uuid)noOnly recordings of this agent.
campaign_idquerystring (uuid)noOnly recordings of this campaign.
limitqueryintegernoPage size (a value over the ceiling is capped, not refused).
cursorquerystringnoOpaque keyset cursor, the next_cursor of the previous page.

Responses

200 ​

The recordings that match, with the fragments around the matches.

FieldTypeRequiredDescription
resultsarray of objectyes
results[].recording_idstring (uuid)yes
results[].started_atstring (date-time)yes
results[].agent_idstring,null (uuid)yes
results[].campaign_idstring,null (uuid)yes
results[].matchesarray of objectyes
results[].matches[].seqintegeryes
results[].matches[].channelintegeryesAudio channel: 0 or 1.
results[].matches[].start_msintegeryes
results[].matches[].end_msintegeryes
results[].matches[].fragmentstringyesThe segment text around the match (about 40 characters of context on each side).
next_cursorstring,nullyesOpaque cursor for the next page; null when there is nothing more to examine.
cappedbooleanyesTrue when the per-call cap on recordings or segments stopped the search before the page was full.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

429 — TranscriptSearchThrottled ​

More than 30 searches (by default) in the fixed window for this dashboard user (core#2505). code rate_limited, with Retry-After. The budget is the person's, so a colleague of the same tenant is not held by it. A refusal by role (403) does not spend it.

json
{
  "error": {
    "code": "rate_limited",
    "message": "transcript search rate limit exceeded; retry later"
  }
}

503 ​

The search event could not be recorded, so no result was returned.

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

Role rules (core#2438): a dd_ key or an account_admin login; a member login is 403 role_forbidden (per-tenant knob compliance_config_admin_only, default on). The GET of this resource is not affected.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

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 in months (default 39). The lowest accepted is the tenant's effective floor (GET /v1/business-types): 39 until it declares its types. The highest accepted is max(3, floor) plus 12 months per block of extra retention bought (core#2423); asking for more is 422.
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Replace the tenant recording policy ​

http
PUT /v1/recording-policy

Full-set of the singleton per-tenant policy. retention_months is clamped to the tenant's EFFECTIVE floor (39 until it declares its business types; see GET /v1/business-types) — a lower value is 422 unprocessable. It also has a CEILING (core#2423): what the plan includes (max(3, floor) months) plus 12 months for each block of extra retention the tenant has bought — a higher value is 422 unprocessable too. A policy already stored above its ceiling keeps its value and may be saved again at that value or lower, never raised. 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).

Role rules (core#2438): a dd_ key or an account_admin login; a member login is 403 role_forbidden (per-tenant knob compliance_config_admin_only, default on). The GET of this resource is not affected.

Request body (JSON, required)

FieldTypeRequiredDescription
enabledbooleanno
retention_monthsintegernoBelow the tenant's effective floor (39 until it declares its types; GET /v1/business-types) → 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 in months (default 39). The lowest accepted is the tenant's effective floor (GET /v1/business-types): 39 until it declares its types. The highest accepted is max(3, floor) plus 12 months per block of extra retention bought (core#2423); asking for more is 422.
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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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 is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Read the tenant's declared business types and the recording-retention floor they imply ​

http
GET /v1/business-types

ADR #246 (core#2065). Answers three things at once so a client never has to join them: the types the tenant DECLARED (declared, null when it declared nothing), the CLOSED vocabulary it may declare with each type's audio floor and legal source (supported), and the EFFECTIVE floor with the type(s) that impose it (retention_floor).

retention_floor.months is legal_floor_months + safety_margin_months: the MAXIMUM audio floor of the declared types plus the platform margin (3 months, inherited from planning, not a legal number). It is NEVER 0: a type whose law asks for no audio still keeps the platform's included retention. A tenant that declared nothing gets the maximum of the supported types (basis: undeclared_maximum) — omitting the declaration can never lower the floor. A floor that cannot be resolved is 503, never a guessed number.

Any console principal reads (a dd_ key or a dashboard login of any role). The sources are US federal rules only; state law is NOT covered (ADR #246, «Límite de la investigación»).

Responses

200 ​

The declaration and the effective floor.

FieldTypeRequiredDescription
business_typesobject (BusinessTypes)yes
business_types.declaredarray,nullyesThe types the tenant declared, sorted. null = it declared nothing.
business_types.supportedarray of BusinessTypeFlooryesThe closed vocabulary a tenant may declare, each with its audio floor and source.
business_types.supported[].typecobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicingyes
business_types.supported[].audio_floor_monthsintegeryesMonths the law asks to keep the AUDIO of a recorded call of this type (0 when the rule does not ask for the audio). Without the platform margin.
business_types.supported[].sourcestringyesThe legal source, as ADR #246 cites it (US federal rules only).
business_types.retention_floorobjectyesThe recording-retention floor in force for NEW recordings.
business_types.retention_floor.monthsintegeryeslegal_floor_months + safety_margin_months. Never below the margin; never 0.
business_types.retention_floor.legal_floor_monthsintegeryesThe maximum audio floor over the declared types (over every supported type when nothing is declared).
business_types.retention_floor.safety_margin_monthsintegeryesThe platform margin added on top of the legal floor (3 today; inherited from planning, not a legal number).
business_types.retention_floor.basisdeclared · undeclared_maximumyesundeclared_maximum = the tenant declared nothing and the floor is the fail-closed maximum of the supported types.
business_types.retention_floor.imposed_byarray of BusinessTypeFlooryesThe type(s) whose audio floor is the one that binds, each with its legal source.
business_types.retention_floor.imposed_by[].typecobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicingyes
business_types.retention_floor.imposed_by[].audio_floor_monthsintegeryesMonths the law asks to keep the AUDIO of a recorded call of this type (0 when the rule does not ask for the audio). Without the platform margin.
business_types.retention_floor.imposed_by[].sourcestringyesThe legal source, as ADR #246 cites it (US federal rules only).

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

503 ​

The tenant's declaration could not be read, so no floor is published.

Declare the tenant's business types ​

http
PUT /v1/business-types

Replaces the declaration. Account administrators only: the caller must be a dashboard login (ddu_) with the account_admin role. A dd_ key reads and gets 403 role_forbidden, and so does a member login — lowering a floor is a decision of a person who answers for it (ADR #246, decision 6).

business_types is REQUIRED: a list drawn from the closed vocabulary (see supported), or null / [] to withdraw the declaration (back to the fail-closed maximum). The list is stored sorted and de-duplicated. A type outside the vocabulary is 422 unprocessable; an absent key, a value that is not a list or null, or an unknown body key is 400 bad_request.

Every change — up or down — writes ONE append-only audit row in the same transaction: who, when, the previous value and the new one, and the floor each implied. Declaring what is already declared writes nothing (changed: false). A lower floor applies to NEW recordings only: retained_until stays frozen per recording and objects already sealed keep their seal.

Request body (JSON, required)

FieldTypeRequiredDescription
business_typesarray,nullyes
json
{
  "business_types": [
    "cobranza"
  ]
}

Responses

200 ​

The declaration now in force and what the change did.

FieldTypeRequiredDescription
business_typesobject (BusinessTypes)yes
business_types.declaredarray,nullyesThe types the tenant declared, sorted. null = it declared nothing.
business_types.supportedarray of BusinessTypeFlooryesThe closed vocabulary a tenant may declare, each with its audio floor and source.
business_types.supported[].typecobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicingyes
business_types.supported[].audio_floor_monthsintegeryesMonths the law asks to keep the AUDIO of a recorded call of this type (0 when the rule does not ask for the audio). Without the platform margin.
business_types.supported[].sourcestringyesThe legal source, as ADR #246 cites it (US federal rules only).
business_types.retention_floorobjectyesThe recording-retention floor in force for NEW recordings.
business_types.retention_floor.monthsintegeryeslegal_floor_months + safety_margin_months. Never below the margin; never 0.
business_types.retention_floor.legal_floor_monthsintegeryesThe maximum audio floor over the declared types (over every supported type when nothing is declared).
business_types.retention_floor.safety_margin_monthsintegeryesThe platform margin added on top of the legal floor (3 today; inherited from planning, not a legal number).
business_types.retention_floor.basisdeclared · undeclared_maximumyesundeclared_maximum = the tenant declared nothing and the floor is the fail-closed maximum of the supported types.
business_types.retention_floor.imposed_byarray of BusinessTypeFlooryesThe type(s) whose audio floor is the one that binds, each with its legal source.
business_types.retention_floor.imposed_by[].typecobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicingyes
business_types.retention_floor.imposed_by[].audio_floor_monthsintegeryesMonths the law asks to keep the AUDIO of a recorded call of this type (0 when the rule does not ask for the audio). Without the platform margin.
business_types.retention_floor.imposed_by[].sourcestringyesThe legal source, as ADR #246 cites it (US federal rules only).
changedbooleanyesfalse when the body declared what was already declared (no audit row).
loweredbooleanyestrue when the effective floor went down; the audit row names who did it.
previous_floor_monthsintegeryesThe effective floor before this call.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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 is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

503 ​

The tenant could not be read, so nothing was declared.

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