Appearance
Recordings
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Call-recording evidence: retention-locked metadata, signed playback, legal hold, bulk export, per-tenant policy.
Endpoints
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Recording id, chosen by the client, one per call. |
n | path | integer | yes | Chunk number, from 0. |
call_uuid | query | string | yes | The call being recorded; it must be the caller's own. |
Responses
200
The chunk was already stored with the same bytes.
| Field | Type | Required | Description |
|---|---|---|---|
chunk | object | yes | |
chunk.recording_id | string (uuid) | yes | |
chunk.seq | integer | yes | |
chunk.bytes | integer | yes | Size of the chunk before sealing. |
chunk.status | stored · duplicate | yes | |
chunk.retained_until | string (date) | yes | Inherited from the voice recording of the same call. |
201
The chunk was sealed and stored.
| Field | Type | Required | Description |
|---|---|---|---|
chunk | object | yes | |
chunk.recording_id | string (uuid) | yes | |
chunk.seq | integer | yes | |
chunk.bytes | integer | yes | Size of the chunk before sealing. |
chunk.status | stored · duplicate | yes | |
chunk.retained_until | string (date) | yes | Inherited 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/gapsThe 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)
| Field | Type | Required | Description |
|---|---|---|---|
call_uuid | string | yes | |
reason | not_armed · agent_stopped · upload_failed | yes | |
note | string | no |
Responses
200
The gap is on file (the first one when repeated).
| Field | Type | Required | Description |
|---|---|---|---|
gap | object | yes | |
gap.call_uuid | string | yes | |
gap.reason | not_armed · agent_stopped · upload_failed | yes | |
gap.source | agent | 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"
}
}Declare which stretches of the OWN seat's screen recording showed PII
http
POST /v1/screen-recordings/{id}/pii-marksThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | The recording id the client chose for the chunk upload. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
marks | array of object | no | |
marks[].start_ms | integer | yes | |
marks[].end_ms | integer | yes | |
marks[].zone | consumer_card · full_number · payment · focus_lost | yes | |
covered_through_ms | integer | yes |
Responses
200
The marks are on file and the coverage is in force.
| Field | Type | Required | Description |
|---|---|---|---|
pii_marks | object | yes | |
pii_marks.recording_id | string (uuid) | yes | |
pii_marks.stored | integer | yes | Marks written by this request. |
pii_marks.duplicates | integer | yes | Marks already on file. |
pii_marks.covered_through_ms | integer | yes | The 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/playbackTakes 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)
| Field | Type | Required | Description |
|---|---|---|---|
call_uuid | string | yes |
Responses
200
The access is on file and the URL is ready.
| Field | Type | Required | Description |
|---|---|---|---|
playback | object | yes | |
playback.recording_id | string (uuid) | yes | |
playback.call_uuid | string | yes | |
playback.variant | redacted | yes | Which video the URL serves. |
playback.url | string | yes | Path under the API origin: /v1/screen-media/{token}. |
playback.expires_at | string (date-time) | yes | |
playback.retained_until | string (date) | yes | |
playback.chunks | integer | yes | |
playback.content_type | video/webm · video/mp4 | yes | |
playback.gaps | array of object | yes | |
playback.gaps[].reason | not_armed · agent_stopped · upload_failed | yes | |
playback.gaps[].source | server · agent | yes | |
playback.gaps[].note | string,null | 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
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-playbackThe 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)
| Field | Type | Required | Description |
|---|---|---|---|
call_uuid | string | yes | |
reason | string | yes | Why the original is needed; stored with the access. |
Responses
200
The access is on file and the URL is ready.
| Field | Type | Required | Description |
|---|---|---|---|
playback | object | yes | |
playback.recording_id | string (uuid) | yes | |
playback.call_uuid | string | yes | |
playback.variant | original | yes | |
playback.url | string | yes | Path under the API origin: /v1/screen-media/{token}. |
playback.expires_at | string (date-time) | yes | |
playback.retained_until | string (date) | yes | |
playback.chunks | integer | yes | |
playback.content_type | video/webm · video/mp4 | yes | |
playback.gaps | array of object | yes | |
playback.gaps[].reason | not_armed · agent_stopped · upload_failed | yes | |
playback.gaps[].source | server · agent | yes | |
playback.gaps[].note | string,null | 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"
}
}410
The recording was purged.
List call recordings (filtered, keyset-paginated)
http
GET /v1/recordingsRecording metadata for the tenant's calls, newest first. The AUDIO is never served here — fetch a short-lived signed URL via POST /v1/recordings/{id}/playback-url. q is a free-text match over the masked phone number, account_ref and any external reference. from/to bound started_at. Foreign/malformed filter ids are indistinguishable from "no match" (RLS), never a leak.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
agent_id | query | string (uuid) | no | |
campaign_id | query | string (uuid) | no | |
disposition | query | string | no | Exact match against an OPEN vocabulary: call_attempts.disposition is written VERBATIM (20260725000003_create_disposition_engine.exs) and the 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. |
q | query | string | no | Free-text search over the masked phone number, account_ref and any external reference. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
A page of recordings.
| Field | Type | Required | Description |
|---|---|---|---|
recordings | array of RecordingListItem | yes | |
recordings[].id | string (uuid) | yes | |
recordings[].call_id | string | yes | The call's FreeSWITCH uuid (= call_attempts.call_uuid). |
recordings[].started_at | string (date-time) | yes | UTC. |
recordings[].local_tz | string | null | no | The recipient's IANA zone (e.g. America/Chicago); the UI renders the local time from it. |
recordings[].agent | object | null | no | Denormalised for display; null for AI/inbound-unattended calls. |
recordings[].account_ref | string | null | no | Debt external reference / account number. |
recordings[].phone_number | string | yes | The other party's E.164. |
recordings[].duration_secs | integer | null | no | |
recordings[].disposition | string | null | no | The typification of the recorded call, DERIVED from its terminal CDR attempt (the latest compensating correction, or the original when none landed). null when the call has no resolvable CDR row. |
recordings[].two_party_consent_state | string | null | no | Set when the recipient's state requires all-party consent; the value is the state code the flag was raised for (e.g. WA). null = not applicable. |
recordings[].attest_a | boolean | no | The outbound leg carried STIR/SHAKEN attestation A. |
recordings[].ai_used | boolean | yes | |
recordings[].retained_until | string (date) | no | Retention floor date, frozen at creation (snapshot doctrine). |
recordings[].legal_hold | boolean | yes | |
recordings[].status | pending · available · purged · lost | no | Where 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_at | string (date-time) | null | no | When the row was closed as lost. Kept if the audio arrived late and the row returned to available. |
recordings[].lost_reason | no_upload_after_call_end · no_upload_call_never_ended | null | no | Why it was closed as lost; null when it never was. |
recordings[].composed_from_parts | boolean | no | true 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_ms | integer (int64) | null | no | The 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_cursor | string | null | yes | Opaque 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-exportsGathers, 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)
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | no | E.164 number. |
consumer_ref | string | no | |
include_pii | boolean | no | Unredacted 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.
| Field | Type | Required | Description |
|---|---|---|---|
subject_export | object (SubjectExport) | yes | |
subject_export.id | string (uuid) | yes | The requested event of the audit trail. |
subject_export.status | done | yes | |
subject_export.include_pii | boolean | yes | |
subject_export.url | string,null | no | Short-lived signed URL of the ZIP. |
subject_export.url_expires_at | string,null (date-time) | no | |
subject_export.manifest | object | yes | Same content as manifest.json inside the ZIP. |
subject_export.manifest.subject | object | yes | |
subject_export.manifest.subject.kind | phone · consumer_ref | no | |
subject_export.manifest.subject.hash | string | no | HMAC of the subject, hex. |
subject_export.manifest.include_pii | boolean | yes | |
subject_export.manifest.generated_at | string (date-time) | no | |
subject_export.manifest.row_cap_per_category | integer | no | |
subject_export.manifest.categories | object | yes | |
subject_export.manifest.truncated | array of string | yes | |
subject_export.manifest.omitted | array of object | yes | |
subject_export.manifest.omitted[].item | string | no | |
subject_export.manifest.omitted[].reason | string | 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"
}
}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-requestsFor 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)
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | no | E.164 number. |
consumer_ref | string | no | |
dry_run | boolean | no | Only true is available in this version. |
json
{
"phone": "+13055550123",
"dry_run": true
}Responses
202
The report of the dry run.
| Field | Type | Required | Description |
|---|---|---|---|
erasure_request | object (ErasureRequest) | yes | |
erasure_request.id | string (uuid) | yes | The privacy_requests row. |
erasure_request.dry_run | boolean | yes | |
erasure_request.report | object | yes | |
erasure_request.report.subject | object | yes | |
erasure_request.report.subject.kind | phone · consumer_ref | no | |
erasure_request.report.subject.hash | string | no | HMAC of the subject, hex. |
erasure_request.report.dry_run | boolean | yes | |
erasure_request.report.generated_at | string (date-time) | no | |
erasure_request.report.records_floor_months | integer | yes | |
erasure_request.report.floor_basis | declared · default_max_of_supported_types | no | |
erasure_request.report.categories | array of object | yes | |
erasure_request.report.categories[].category | string | no | |
erasure_request.report.categories[].tables | array of string | no | |
erasure_request.report.categories[].found | integer | no | |
erasure_request.report.categories[].would_anonymize | integer | no | |
erasure_request.report.categories[].action | anonymize · none | no | |
erasure_request.report.categories[].events | integer | no | AI conversations only: their events. |
erasure_request.report.categories[].retained | array of object | no | |
erasure_request.report.derived_copies | array of object | yes | |
erasure_request.report.derived_copies[].item | string | no | |
erasure_request.report.derived_copies[].status | string | no | |
erasure_request.report.derived_copies[].until | string,null | no | |
erasure_request.report.derived_copies[].reason | string | no | |
erasure_request.report.execution | not_available | 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"
}
}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-exportsEnqueues an asynchronous export of the recordings matching the same filters as GET /v1/recordings. Returns 202 with a job_id; poll GET /v1/recording-exports/{job_id} for the signed archive URL. The export job row is durable evidence of who pulled bulk recordings and is never deleted.
An explicit from/to range wider than the platform limit (recording_export_max_range_days, default 366 days) — or a far-past from with no upper bound — is rejected with 422 range_too_large. An unbounded request (no from) is still accepted, 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)
| Field | Type | Required | Description |
|---|---|---|---|
from | string (date-time) | no | |
to | string (date-time) | no | |
agent_id | string (uuid) | no | |
campaign_id | string (uuid) | no | |
disposition | string | no | |
q | string | no | |
refresh | boolean | no | Bypass the short-lived dedup window and force a fresh export even if an identical one was just produced. |
include_pii | boolean | no | true 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.
| Field | Type | Required | Description |
|---|---|---|---|
export | object (RecordingExportJob) | yes | |
export.job_id | string (uuid) | yes | |
export.status | queued · running · done · failed · expired | yes | expired is reported on read for a done job whose archive is no longer in the export store; regenerate with a new export request. |
export.pii | redacted · raw | no | Whether the archive was produced with the PII of the closed ADR #240 catalog redacted (default) or raw (include_pii: true). |
export.url | string | null | no | Signed archive URL; present only when status is done. |
export.expires_at | string (date-time) | null | no | |
export.row_count | integer | null | no | |
export.error | string | null | no | |
export.created_at | string (date-time) | no | |
export.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
job_id | path | string (uuid) | yes | Export-job UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The export job.
| Field | Type | Required | Description |
|---|---|---|---|
export | object (RecordingExportJob) | yes | |
export.job_id | string (uuid) | yes | |
export.status | queued · running · done · failed · expired | yes | expired is reported on read for a done job whose archive is no longer in the export store; regenerate with a new export request. |
export.pii | redacted · raw | no | Whether the archive was produced with the PII of the closed ADR #240 catalog redacted (default) or raw (include_pii: true). |
export.url | string | null | no | Signed archive URL; present only when status is done. |
export.expires_at | string (date-time) | null | no | |
export.row_count | integer | null | no | |
export.error | string | null | no | |
export.created_at | string (date-time) | no | |
export.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The recording.
| Field | Type | Required | Description |
|---|---|---|---|
recording | any | yes |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). 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-urlReturns a short-lived signed object-store URL (Cloudflare R2 / S3-compatible) the browser fetches directly — the audio NEVER transits the API server. expires_at is the URL's hard expiry (minutes). Each mint is audited.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
A freshly signed URL.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Short-lived signed object-store URL. |
expires_at | string (date-time) | yes |
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-urlThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | yes |
json
{
"reason": "Dispute 2026-0417: customer denies the payment promise"
}Responses
200
A freshly signed URL for the original audio.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Short-lived signed object-store URL. |
expires_at | string (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}/redactionSpans (time and category only, never the value), method and version. 404 when the recording has no redaction.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The redaction.
| Field | Type | Required | Description |
|---|---|---|---|
redaction | object (RecordingRedaction) | no | |
redaction.recording_id | string (uuid) | no | |
redaction.method | silence · beep | no | |
redaction.segments | array of RecordingRedactionSegment | no | |
redaction.segments[].start_ms | integer | yes | |
redaction.segments[].end_ms | integer | yes | |
redaction.segments[].category | card · ssn · bank_account · pin | yes | |
redaction.redacted_ms | integer | no | |
redaction.version | integer | no | |
redaction.codec | string | no | |
redaction.requested_by | string | no | |
redaction.created_at | string (date-time) | no | |
redaction.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). 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}/redactionSame 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
method | silence · beep | no | |
segments | array of RecordingRedactionSegment | yes | |
segments[].start_ms | integer | yes | |
segments[].end_ms | integer | yes | |
segments[].category | card · ssn · bank_account · pin | yes |
Responses
200
The redaction after the call.
| Field | Type | Required | Description |
|---|---|---|---|
redaction | object (RecordingRedaction) | no | |
redaction.recording_id | string (uuid) | no | |
redaction.method | silence · beep | no | |
redaction.segments | array of RecordingRedactionSegment | no | |
redaction.segments[].start_ms | integer | yes | |
redaction.segments[].end_ms | integer | yes | |
redaction.segments[].category | card · ssn · bank_account · pin | yes | |
redaction.redacted_ms | integer | no | |
redaction.version | integer | no | |
redaction.codec | string | no | |
redaction.requested_by | string | no | |
redaction.created_at | string (date-time) | no | |
redaction.updated_at | string (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}/transcriptThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The transcript.
| Field | Type | Required | Description |
|---|---|---|---|
transcript | object (RecordingTranscript) | yes | |
transcript.recording_id | string (uuid) | yes | |
transcript.redacted | boolean | yes | true for the default view (PII redacted); false only on the audited original. |
transcript.provider | string | yes | Speech-to-text provider that produced it. |
transcript.model | string | yes | |
transcript.language | string,null | no | Language the provider reported, when it reported one. |
transcript.audio_seconds | integer | yes | Seconds of audio billed. |
transcript.segment_count | integer | yes | |
transcript.created_at | string (date-time) | yes | |
transcript.segments | array of object | yes | |
transcript.segments[].seq | integer | yes | |
transcript.segments[].channel | integer | yes | Audio channel: 0 or 1. |
transcript.segments[].start_ms | integer | yes | |
transcript.segments[].end_ms | integer | yes | |
transcript.segments[].text | string | yes |
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/originalThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | yes |
json
{
"reason": "Dispute 2026-0417: customer denies the payment promise"
}Responses
200
The transcript, with redacted: false.
| Field | Type | Required | Description |
|---|---|---|---|
transcript | object (RecordingTranscript) | yes | |
transcript.recording_id | string (uuid) | yes | |
transcript.redacted | boolean | yes | true for the default view (PII redacted); false only on the audited original. |
transcript.provider | string | yes | Speech-to-text provider that produced it. |
transcript.model | string | yes | |
transcript.language | string,null | no | Language the provider reported, when it reported one. |
transcript.audio_seconds | integer | yes | Seconds of audio billed. |
transcript.segment_count | integer | yes | |
transcript.created_at | string (date-time) | yes | |
transcript.segments | array of object | yes | |
transcript.segments[].seq | integer | yes | |
transcript.segments[].channel | integer | yes | Audio channel: 0 or 1. |
transcript.segments[].start_ms | integer | yes | |
transcript.segments[].end_ms | integer | yes | |
transcript.segments[].text | string | 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"
}
}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/searchFinds 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
q | query | string | yes | The word or phrase (1 to 8 words). |
from | query | string (date-time) | yes | RFC 3339 lower bound on the recording start (inclusive). |
to | query | string (date-time) | yes | RFC 3339 upper bound on the recording start; at most 31 days after from. |
agent_id | query | string (uuid) | no | Only recordings of this agent. |
campaign_id | query | string (uuid) | no | Only recordings of this campaign. |
limit | query | integer | no | Page size (a value over the ceiling is capped, not refused). |
cursor | query | string | no | Opaque keyset cursor, the next_cursor of the previous page. |
Responses
200
The recordings that match, with the fragments around the matches.
| Field | Type | Required | Description |
|---|---|---|---|
results | array of object | yes | |
results[].recording_id | string (uuid) | yes | |
results[].started_at | string (date-time) | yes | |
results[].agent_id | string,null (uuid) | yes | |
results[].campaign_id | string,null (uuid) | yes | |
results[].matches | array of object | yes | |
results[].matches[].seq | integer | yes | |
results[].matches[].channel | integer | yes | Audio channel: 0 or 1. |
results[].matches[].start_ms | integer | yes | |
results[].matches[].end_ms | integer | yes | |
results[].matches[].fragment | string | yes | The segment text around the match (about 40 characters of context on each side). |
next_cursor | string,null | yes | Opaque cursor for the next page; null when there is nothing more to examine. |
capped | boolean | yes | True 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-holdIdempotent full-set of the recording's legal-hold sub-resource. active: true REQUIRES a non-empty reason. A held recording is exempt from every purge until the hold is lifted, even past its retention floor. Tenant-management scope: a dd_ tenant API key only — a ddw_ browser token is 403. Setting the hold is audited (set_by, set_at).
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
active | boolean | yes | |
reason | string | no | REQUIRED (non-empty) when active is true; ignored when false. |
json
{
"active": true,
"reason": "Litigation hold — Apex Recovery v. Doe, matter #2026-0417"
}Responses
200
The recording with the updated hold.
| Field | Type | Required | Description |
|---|---|---|---|
recording | any | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
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-policyResponses
200
The effective policy (defaults materialised on first read).
| Field | Type | Required | Description |
|---|---|---|---|
recording_policy | object (RecordingPolicy) | yes | |
recording_policy.enabled | boolean | yes | Whether new calls are recorded by default. |
recording_policy.retention_months | integer | yes | Retention 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.channels | mono · stereo | yes | |
recording_policy.format | wav · opus | yes | Storage codec for NEW recordings. Never re-encodes what is already stored, and changing it never re-dates a retention: both are frozen per recording at creation. opus is the default a new tenant is born with (it divides the retained footprint by ~8); wav is the value to keep for a tenant under a regulatory, contractual or litigation obligation to hold uncompressed audio, and it carries a surcharge — see RecordingPolicyWrite.format. A tenant already on wav stays on wav: migration 20260907000100 first materialises a wav policy row for every tenant that exists on 2026-09-07, so the new default reaches only tenants created from that date on. Without that step it would also reach every older tenant that had never called this endpoint, because its row is created lazily. |
recording_policy.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). 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-policyFull-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)
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | no | |
retention_months | integer | no | Below the tenant's effective floor (39 until it declares its types; GET /v1/business-types) → 422 unprocessable. |
channels | mono · stereo | no | |
format | wav · opus | no | Storage codec for NEW recordings; outside the vocabulary → 400 bad_request naming the field (the 422 rung is reserved for the retention floor, which is a business-rule refusal). opus is the default and the cheap path. wav is the per-tenant exception in two cases and only two — a regulatory, contractual or litigation obligation to hold uncompressed audio, or the customer asking for it expressly — and it will be billed: the price is settled (DialerDigital/macro-tasks#475, 2026-09-06) at a flat 5.00 USD per seat and month while a tenant's format is wav, counted at the last instant of the closed month, with no exemption for the tenant under a legal obligation (founder's codec decision in freeswitch ADR.md #15, 2026-09-04). The wav_surcharge line IS emitted: the monthly close bills it since DialerDigital/core#779 (ADR #81), on the codec the tenant had at the period boundary — read from the append-only format history, not from the policy row as it stands today — and the same billable seats the seats line uses. Switching format affects only recordings made AFTER the change. Nothing stored is transcoded or re-dated: recordings sit under Object Lock with a 39-month retention floor frozen per row. This enum is mirrored by Recordings.Policy.writable_formats/0 and may legitimately be NARROWER than the read side's: admitting a codec on READ is a catalog fact, minting one on WRITE is a product position. It was [wav] until DialerDigital/core#731 and widened in one change with writable_formats/0 and the column DEFAULT, because a default nobody can also SET is a half-open gate. The FreeSWITCH chart's recording.format is NOT this axis and does not choose the codec: it is per RELEASE while this is per TENANT, and its guard still rejects any value but wav (deploy/chart/templates/_helpers.tpl). What lands on disk is the extension of the object key core hands uuid_record (Recordings.object_key/4), derived from this field. HISTORY, so retired reasons are not resurrected. (a) Until DialerDigital/freeswitch#85 this enum was justified by the upload sidecar resolving a SINGLE extension — a tenant on Opus would have FreeSWITCH write .opus, the sidecar find zero files, and nothing turn red. THAT IS FIXED: the sidecar discovers the whole handled set and quarantines anything outside it, naming it (freeswitch ADR.md #16). Deliberately no line numbers here: the previous text pointed at uploader.sh:94, which that very fix left blank. (b) Do NOT re-add "voice-ai cannot read non-WAV": measured and refuted (DialerDigital/voice-ai#70, closed not-planned 2026-09-04) — voice-ai consumes raw L16 over the media fork and never reads a stored recording, so the storage codec and what voice-ai ingests are independent variables. |
json
{
"enabled": true,
"retention_months": 39,
"channels": "stereo"
}Responses
200
The updated policy.
| Field | Type | Required | Description |
|---|---|---|---|
recording_policy | object (RecordingPolicy) | yes | |
recording_policy.enabled | boolean | yes | Whether new calls are recorded by default. |
recording_policy.retention_months | integer | yes | Retention 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.channels | mono · stereo | yes | |
recording_policy.format | wav · opus | yes | Storage codec for NEW recordings. Never re-encodes what is already stored, and changing it never re-dates a retention: both are frozen per recording at creation. opus is the default a new tenant is born with (it divides the retained footprint by ~8); wav is the value to keep for a tenant under a regulatory, contractual or litigation obligation to hold uncompressed audio, and it carries a surcharge — see RecordingPolicyWrite.format. A tenant already on wav stays on wav: migration 20260907000100 first materialises a wav policy row for every tenant that exists on 2026-09-07, so the new default reaches only tenants created from that date on. Without that step it would also reach every older tenant that had never called this endpoint, because its row is created lazily. |
recording_policy.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
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-typesADR #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.
| Field | Type | Required | Description |
|---|---|---|---|
business_types | object (BusinessTypes) | yes | |
business_types.declared | array,null | yes | The types the tenant declared, sorted. null = it declared nothing. |
business_types.supported | array of BusinessTypeFloor | yes | The closed vocabulary a tenant may declare, each with its audio floor and source. |
business_types.supported[].type | cobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicing | yes | |
business_types.supported[].audio_floor_months | integer | yes | Months 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[].source | string | yes | The legal source, as ADR #246 cites it (US federal rules only). |
business_types.retention_floor | object | yes | The recording-retention floor in force for NEW recordings. |
business_types.retention_floor.months | integer | yes | legal_floor_months + safety_margin_months. Never below the margin; never 0. |
business_types.retention_floor.legal_floor_months | integer | yes | The maximum audio floor over the declared types (over every supported type when nothing is declared). |
business_types.retention_floor.safety_margin_months | integer | yes | The platform margin added on top of the legal floor (3 today; inherited from planning, not a legal number). |
business_types.retention_floor.basis | declared · undeclared_maximum | yes | undeclared_maximum = the tenant declared nothing and the floor is the fail-closed maximum of the supported types. |
business_types.retention_floor.imposed_by | array of BusinessTypeFloor | yes | The type(s) whose audio floor is the one that binds, each with its legal source. |
business_types.retention_floor.imposed_by[].type | cobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicing | yes | |
business_types.retention_floor.imposed_by[].audio_floor_months | integer | yes | Months 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[].source | string | yes | The 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-typesReplaces 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)
| Field | Type | Required | Description |
|---|---|---|---|
business_types | array,null | yes |
json
{
"business_types": [
"cobranza"
]
}Responses
200
The declaration now in force and what the change did.
| Field | Type | Required | Description |
|---|---|---|---|
business_types | object (BusinessTypes) | yes | |
business_types.declared | array,null | yes | The types the tenant declared, sorted. null = it declared nothing. |
business_types.supported | array of BusinessTypeFloor | yes | The closed vocabulary a tenant may declare, each with its audio floor and source. |
business_types.supported[].type | cobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicing | yes | |
business_types.supported[].audio_floor_months | integer | yes | Months 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[].source | string | yes | The legal source, as ADR #246 cites it (US federal rules only). |
business_types.retention_floor | object | yes | The recording-retention floor in force for NEW recordings. |
business_types.retention_floor.months | integer | yes | legal_floor_months + safety_margin_months. Never below the margin; never 0. |
business_types.retention_floor.legal_floor_months | integer | yes | The maximum audio floor over the declared types (over every supported type when nothing is declared). |
business_types.retention_floor.safety_margin_months | integer | yes | The platform margin added on top of the legal floor (3 today; inherited from planning, not a legal number). |
business_types.retention_floor.basis | declared · undeclared_maximum | yes | undeclared_maximum = the tenant declared nothing and the floor is the fail-closed maximum of the supported types. |
business_types.retention_floor.imposed_by | array of BusinessTypeFloor | yes | The type(s) whose audio floor is the one that binds, each with its legal source. |
business_types.retention_floor.imposed_by[].type | cobranza · telemarketing · servicio_cliente · financiera_valores · hipotecas_servicing | yes | |
business_types.retention_floor.imposed_by[].audio_floor_months | integer | yes | Months 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[].source | string | yes | The legal source, as ADR #246 cites it (US federal rules only). |
changed | boolean | yes | false when the body declared what was already declared (no audit row). |
lowered | boolean | yes | true when the effective floor went down; the audit row names who did it. |
previous_floor_months | integer | yes | The 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.