Skip to content

Voice ai ​

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

Español

AI voice agents: builder + versioned policy, live conversations, transcripts/outcomes, supervisor takeover, the per-tenant AI-dialing kill-switch and today's metrics.

Endpoints ​

MethodPathSummary
GET/v1/ai/agentsList AI voice agents
POST/v1/ai/agentsCreate an AI agent (status draft)
GET/v1/ai/voicesThe governed AI voice catalog
GET/v1/ai/agents/driftVoice-AI drift alerts, with their evidence and what is being watched
GET/v1/ai/agents/{id}Fetch an AI agent
PATCH/v1/ai/agents/{id}Update an AI agent (bumps policy_version)
DELETE/v1/ai/agents/{id}Retire an AI agent (never deletes)
POST/v1/ai/agents/{id}/testSandbox conversation (no live call)
POST/v1/ai/agents/{id}/voice-previewHear the agent's voice (fixed sample sentence)
GET/v1/ai/agents/{id}/experimentsA/B experiments of an AI agent
POST/v1/ai/agents/{id}/experimentsCreate an A/B experiment of an AI agent (draft)
GET/v1/ai/agents/{id}/experiments/{experiment_id}Fetch an A/B experiment of an AI agent
PATCH/v1/ai/agents/{id}/experiments/{experiment_id}Edit a draft A/B experiment of an AI agent
DELETE/v1/ai/agents/{id}/experiments/{experiment_id}Delete a draft A/B experiment of an AI agent
GET/v1/ai/agents/{id}/experiments/{experiment_id}/resultsResults and honest verdict of an A/B experiment of an AI agent
POST/v1/ai/agents/{id}/experiments/{experiment_id}/startStart an A/B experiment of an AI agent
POST/v1/ai/agents/{id}/experiments/{experiment_id}/stopEnd an A/B experiment of an AI agent
GET/v1/ai/conversationsList AI conversations (live feed or history)
GET/v1/ai/conversations/{id}Fetch a conversation with transcript and outcome
POST/v1/ai/conversations/{id}/takeoverSupervisor takeover of a live AI conversation
POST/v1/ai/pauseKill-switch — pause all AI dialing (tenant-wide)
POST/v1/ai/resumeKill-switch — resume AI dialing (tenant-wide)
GET/v1/ai/statusAI-dialing kill-switch state
GET/v1/ai/metrics/todayToday's AI operating metrics (UTC day)
GET/v1/ai/eligibilityPer-campaign artificial-voice eligibility

List AI voice agents ​

http
GET /v1/ai/agents

Responses

200 ​

All AI agents of the tenant.

FieldTypeRequiredDescription
ai_agentsarray of AiAgentyes
ai_agents[].idstring (uuid)yes
ai_agents[].namestringyes
ai_agents[].statusdraft · live · paused · retiredyes
ai_agents[].voiceobject (AiVoice)no
ai_agents[].voice.languagestringyesBCP-47 (e.g. en-US).
ai_agents[].voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
ai_agents[].voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
ai_agents[].disclosureobject (AiDisclosure)no
ai_agents[].disclosure.textstringyesThe opening artificial/prerecorded-voice disclosure (content-validated, EN/ES).
ai_agents[].disclosure.mandatedtrueyesAlways true — the disclosure cannot be disabled.
ai_agents[].disclosure.lockedbooleannoTrue once the agent has been live; text is then immutable.
ai_agents[].policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
ai_agents[].policy.max_settlement_pctintegernoMax settlement as a percent of balance.
ai_agents[].policy.min_payment_centsintegerno
ai_agents[].policy.max_plan_monthsintegerno
ai_agents[].policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
ai_agents[].policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
ai_agents[].handoff_rulesarray of AiHandoffRuleno
ai_agents[].handoff_rules[].idstring (uuid) | nullno
ai_agents[].handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
ai_agents[].handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
ai_agents[].handoff_rules[].actiontransfer · offer_human · confirm_humanyes
ai_agents[].handoff_rules[].severityhard · softyes
ai_agents[].handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
ai_agents[].policy_versionintegeryesMonotonic; increments on every versioned change. Transcripts cite it.
ai_agents[].created_atstring (date-time)no
ai_agents[].updated_atstring (date-time)no

401 — Unauthorized ​

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

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

429 — ApiKeyRateLimited ​

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

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

Create an AI agent (status draft) ​

http
POST /v1/ai/agents

Creates a draft agent at policy_version: 1. The opening disclosure.text is mandated, content-validated (must self-identify the voice as automated, EN/ES) and becomes IMMUTABLE the first time the agent transitions to live. A human_requested handoff rule is always present and locked. Duplicate name → 409. voice.voice_id, when given, must be one of GET /v1/ai/voices (otherwise 422). Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
statusdraft · live · paused · retiredno
voiceobject (AiVoice)no
voice.languagestringyesBCP-47 (e.g. en-US).
voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
disclosureobjectno
disclosure.textstringno
policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
policy.max_settlement_pctintegernoMax settlement as a percent of balance.
policy.min_payment_centsintegerno
policy.max_plan_monthsintegerno
policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
handoff_rulesarray of AiHandoffRuleno
handoff_rules[].idstring (uuid) | nullno
handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
handoff_rules[].actiontransfer · offer_human · confirm_humanyes
handoff_rules[].severityhard · softyes
handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
json
{
  "name": "Ava — Collections Assistant",
  "voice": {
    "language": "en",
    "voice_id": "b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1",
    "pace": 0.5
  },
  "disclosure": {
    "text": "This call is from Ava, an automated assistant calling on behalf of Brightpath Recovery Group regarding an account. This call is recorded."
  },
  "policy": {
    "max_settlement_pct": 70,
    "min_payment_cents": 5000,
    "max_plan_months": 12,
    "take_payments": "none",
    "prohibited": [
      "legal threats",
      "third-party disclosure",
      "other debts"
    ]
  },
  "handoff_rules": [
    {
      "trigger": "keywords",
      "params": {
        "keywords": [
          "attorney",
          "lawyer",
          "dispute",
          "cease",
          "bankruptcy"
        ]
      },
      "action": "transfer",
      "severity": "hard"
    }
  ]
}

Responses

201 ​

Created (status draft).

FieldTypeRequiredDescription
ai_agentobject (AiAgent)yes
ai_agent.idstring (uuid)yes
ai_agent.namestringyes
ai_agent.statusdraft · live · paused · retiredyes
ai_agent.voiceobject (AiVoice)no
ai_agent.voice.languagestringyesBCP-47 (e.g. en-US).
ai_agent.voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
ai_agent.voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
ai_agent.disclosureobject (AiDisclosure)no
ai_agent.disclosure.textstringyesThe opening artificial/prerecorded-voice disclosure (content-validated, EN/ES).
ai_agent.disclosure.mandatedtrueyesAlways true — the disclosure cannot be disabled.
ai_agent.disclosure.lockedbooleannoTrue once the agent has been live; text is then immutable.
ai_agent.policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
ai_agent.policy.max_settlement_pctintegernoMax settlement as a percent of balance.
ai_agent.policy.min_payment_centsintegerno
ai_agent.policy.max_plan_monthsintegerno
ai_agent.policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
ai_agent.policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
ai_agent.handoff_rulesarray of AiHandoffRuleno
ai_agent.handoff_rules[].idstring (uuid) | nullno
ai_agent.handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
ai_agent.handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
ai_agent.handoff_rules[].actiontransfer · offer_human · confirm_humanyes
ai_agent.handoff_rules[].severityhard · softyes
ai_agent.handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
ai_agent.policy_versionintegeryesMonotonic; increments on every versioned change. Transcripts cite it.
ai_agent.created_atstring (date-time)no
ai_agent.updated_atstring (date-time)no

400 — BadRequest ​

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

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

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

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

403 — AiAgentWriteForbidden ​

An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
  }
}

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

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

The governed AI voice catalog ​

http
GET /v1/ai/voices

The voices an AI agent may speak with (core#1539): the ONLY accepted values of voice.voice_id on POST /v1/ai/agents and PATCH /v1/ai/agents/{id}. Versioned data in the control plane, the same for every tenant; adding a voice is a release. language is the primary BCP-47 subtag the voice speaks (en); ?language= filters by it and also takes a full tag (en-US selects en). A language with no voice is an empty list, not an error. Same audience as GET /v1/ai/agents.

Parameters

NameInTypeRequiredDescription
languagequerystringnoPrimary language subtag (or a full BCP-47 tag) to filter by.

Responses

200 ​

The voices, in catalog order.

FieldTypeRequiredDescription
voicesarray of AiVoiceCatalogEntryyes
voices[].voice_idstringyesThe provider's voice identifier — what an agent stores in voice.voice_id.
voices[].providerstringyesWho synthesizes it (e.g. cartesia).
voices[].languagestringyesPrimary BCP-47 subtag the voice speaks (e.g. en).
voices[].labelstringyesThe name an operator sees.

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

Voice-AI drift alerts, with their evidence and what is being watched ​

http
GET /v1/ai/agents/drift

The alerts of the drift evaluator, which runs for EVERY tenant every 15 minutes (always on: there is no activation flag, and drift changes no agent, experiment or gate). It reads the hourly buckets (UTC, by the hour the call closed) of ai_conversations + ai_call_metrics per scope, (agent, variant | none) and (model), for five metrics: first_audio_p95_ms, handoff_rate, provider_error_rate, failed_rate and cost_per_call. Two rules judge them: zscore.v1 (the moving band of the insights evaluator: base of the previous 7 days, at least 24 buckets and 30 calls, k = 3.0, only the harmful direction, which is upwards for all five) and budget.v1 (the p95 of the first audio of the bucket above the latency budget, 900 ms).

Every alert is its opened row with the evidence it was written with; a cleared row that cites it makes state cleared and fills resolution. Newest first; page with cursor.

coverage says what is being judged: a series whose last bucket had fewer than 30 calls is insufficient_sample and one without 24 baseline buckets is insufficient_baseline. An empty drift list is NOT "all fine" for those series.

agent_id, variant_id and model are different scopes: model cannot be combined with the other two (400). Needs the tenant scope (a dd_ key or a dashboard login).

Parameters

NameInTypeRequiredDescription
agent_idquerystring (uuid)noOnly this AI agent's scope.
variant_idquerystringnoOnly this variant of the agent, or base for the agent without a variant.
modelquerystringnoOnly the scope of every call that used this model.
metricqueryfirst_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_callno
statequeryopen · clearedno
limitqueryintegernoPage size, 50 by default and at most 200.
cursorquerystringnoThe next_cursor of the previous page.

Responses

200 ​

A page of alerts and the coverage of the watched series.

FieldTypeRequiredDescription
driftarray of AiDriftEventyes
drift[].idstring (uuid)yes
drift[].scopeagent · modelyes
drift[].ai_agent_idstring,null (uuid)yesnull in the model scope.
drift[].variant_idstring,null (uuid)yesnull for the base agent and in the model scope.
drift[].modelstring,nullyesOnly in the model scope.
drift[].metricfirst_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_callyesThe five series the drift evaluator watches, each by hourly bucket (UTC, by the hour the call closed): the p95 of the calls' first-audio latency in ms, the share of valid calls handed to a human, the share of measured calls with a provider error, the share of calls that failed, and the mean cost of the priced calls in micro-USD. Every one alerts upwards; an improvement never alerts.
drift[].rulezscore.v1 · budget.v1yes
drift[].directionup · downyesThe harmful direction that was crossed.
drift[].stateopen · clearedyes
drift[].window_startstring (date-time)yesThe hourly bucket that crossed the threshold (inclusive).
drift[].window_endstring (date-time)yesThe same bucket (exclusive).
drift[].observednumberyesThe bucket value.
drift[].baseline_meannumber,nullyesnull for budget.v1.
drift[].baseline_stddevnumber,nullyesSample standard deviation of the baseline buckets; null for budget.v1.
drift[].stddev_floornumber,nullyesThe floor applied to the deviation; null for budget.v1.
drift[].thresholdnumberyeszscore.v1: baseline_mean + k·max(baseline_stddev, stddev_floor); budget.v1: the latency budget in ms.
drift[].knumber,nullyesThe sensitivity of zscore.v1 (3.0); null for budget.v1.
drift[].sample_nintegeryesCalls in the bucket.
drift[].baseline_ninteger,nullyesBaseline buckets used; null for budget.v1.
drift[].seriesarray of AiDriftSeriesPointyesThe buckets the rule looked at followed by the bucket that crossed, oldest first.
drift[].series[].tstring (date-time)yesStart of the hourly bucket.
drift[].series[].sampleintegeryesCalls in the bucket.
drift[].series[].numeratornumbernoOnly for the ratio metrics.
drift[].series[].denominatornumbernoOnly for the ratio metrics.
drift[].series[].valuenumber,nullyes
drift[].opened_atstring (date-time)yes
drift[].resolutionobject,nullyesThe row that cleared it; null while open.
drift[].resolution.idstring (uuid)yes
drift[].resolution.window_startstring (date-time)yes
drift[].resolution.window_endstring (date-time)yes
drift[].resolution.observednumberyes
drift[].resolution.cleared_atstring (date-time)yes
coveragearray of AiDriftCoverageyes
coverage[].scopeagent · modelyes
coverage[].ai_agent_idstring,null (uuid)yes
coverage[].variant_idstring,null (uuid)yes
coverage[].modelstring,nullyes
coverage[].metricfirst_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_callyesThe five series the drift evaluator watches, each by hourly bucket (UTC, by the hour the call closed): the p95 of the calls' first-audio latency in ms, the share of valid calls handed to a human, the share of measured calls with a provider error, the share of calls that failed, and the mean cost of the priced calls in micro-USD. Every one alerts upwards; an improvement never alerts.
coverage[].statusok · insufficient_sample · insufficient_baselineyes
coverage[].window_startstring (date-time)yesThe last hourly bucket with calls.
coverage[].sample_nintegeryes
coverage[].baseline_nintegeryes
coverage[].min_sampleintegeryes
coverage[].min_baselineintegeryes
next_cursorstring,nullyesnull 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"
  }
}

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

Fetch an AI agent ​

http
GET /v1/ai/agents/{id}

Parameters

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

Responses

200 ​

The AI agent.

FieldTypeRequiredDescription
ai_agentobject (AiAgent)yes
ai_agent.idstring (uuid)yes
ai_agent.namestringyes
ai_agent.statusdraft · live · paused · retiredyes
ai_agent.voiceobject (AiVoice)no
ai_agent.voice.languagestringyesBCP-47 (e.g. en-US).
ai_agent.voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
ai_agent.voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
ai_agent.disclosureobject (AiDisclosure)no
ai_agent.disclosure.textstringyesThe opening artificial/prerecorded-voice disclosure (content-validated, EN/ES).
ai_agent.disclosure.mandatedtrueyesAlways true — the disclosure cannot be disabled.
ai_agent.disclosure.lockedbooleannoTrue once the agent has been live; text is then immutable.
ai_agent.policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
ai_agent.policy.max_settlement_pctintegernoMax settlement as a percent of balance.
ai_agent.policy.min_payment_centsintegerno
ai_agent.policy.max_plan_monthsintegerno
ai_agent.policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
ai_agent.policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
ai_agent.handoff_rulesarray of AiHandoffRuleno
ai_agent.handoff_rules[].idstring (uuid) | nullno
ai_agent.handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
ai_agent.handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
ai_agent.handoff_rules[].actiontransfer · offer_human · confirm_humanyes
ai_agent.handoff_rules[].severityhard · softyes
ai_agent.handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
ai_agent.policy_versionintegeryesMonotonic; increments on every versioned change. Transcripts cite it.
ai_agent.created_atstring (date-time)no
ai_agent.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

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

Update an AI agent (bumps policy_version) ​

http
PATCH /v1/ai/agents/{id}

Any change to voice / disclosure / policy / handoff_rules / status INCREMENTS policy_version and appends an immutable snapshot (transcripts cite the version in force); a name-only edit does not bump. Constraints: once the agent has been live, disclosure.text is immutable (422); a new voice.voice_id must be in GET /v1/ai/voices (422) — the id the agent ALREADY stores is accepted even when out of the catalog, so a pre-catalog agent stays editable; policy_version is monotonic; the human_requested handoff rule stays locked. status moves within draft|live|paused|retired; moving it to retired while an ACTIVE inbound route of type ai_agent still names the agent is 409 naming the route(s) (core#1547) — disable or re-point them first; pausing is never blocked. Going live needs explicit guards (core#2459): policy.max_settlement_pct, min_payment_cents, max_plan_months, at least one prohibited phrase, and a payment_over handoff rule when take_payments is card_on_file. Server switch ai_agent_live_requires_guards: off, observe (default: 200 plus warnings) or enforce (422 policy_incomplete naming the missing keys: refused are the transition into live and an edit of a live agent that ADDS a missing key; an agent already live without guards is never cut and answers warnings). Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
statusdraft · live · paused · retiredno
voiceobject (AiVoice)no
voice.languagestringyesBCP-47 (e.g. en-US).
voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
disclosureobjectno
disclosure.textstringno
policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
policy.max_settlement_pctintegernoMax settlement as a percent of balance.
policy.min_payment_centsintegerno
policy.max_plan_monthsintegerno
policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
handoff_rulesarray of AiHandoffRuleno
handoff_rules[].idstring (uuid) | nullno
handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
handoff_rules[].actiontransfer · offer_human · confirm_humanyes
handoff_rules[].severityhard · softyes
handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
json
{
  "status": "live",
  "policy": {
    "max_settlement_pct": 65
  }
}

Responses

200 ​

The updated agent (new policy_version).

FieldTypeRequiredDescription
ai_agentobject (AiAgent)yes
ai_agent.idstring (uuid)yes
ai_agent.namestringyes
ai_agent.statusdraft · live · paused · retiredyes
ai_agent.voiceobject (AiVoice)no
ai_agent.voice.languagestringyesBCP-47 (e.g. en-US).
ai_agent.voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
ai_agent.voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
ai_agent.disclosureobject (AiDisclosure)no
ai_agent.disclosure.textstringyesThe opening artificial/prerecorded-voice disclosure (content-validated, EN/ES).
ai_agent.disclosure.mandatedtrueyesAlways true — the disclosure cannot be disabled.
ai_agent.disclosure.lockedbooleannoTrue once the agent has been live; text is then immutable.
ai_agent.policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
ai_agent.policy.max_settlement_pctintegernoMax settlement as a percent of balance.
ai_agent.policy.min_payment_centsintegerno
ai_agent.policy.max_plan_monthsintegerno
ai_agent.policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
ai_agent.policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
ai_agent.handoff_rulesarray of AiHandoffRuleno
ai_agent.handoff_rules[].idstring (uuid) | nullno
ai_agent.handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
ai_agent.handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
ai_agent.handoff_rules[].actiontransfer · offer_human · confirm_humanyes
ai_agent.handoff_rules[].severityhard · softyes
ai_agent.handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
ai_agent.policy_versionintegeryesMonotonic; increments on every versioned change. Transcripts cite it.
ai_agent.created_atstring (date-time)no
ai_agent.updated_atstring (date-time)no
warningsarray of objectnoPresent in observe and enforce mode when the agent is live with guards missing.
warnings[].codepolicy_incompleteno
warnings[].messagestringno
warnings[].missingarray of stringno

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 — AiAgentWriteForbidden ​

An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
  }
}

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

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

Retire an AI agent (never deletes) ​

http
DELETE /v1/ai/agents/{id}

Soft-retire (status retired): the row and all its version snapshots survive because live/historical conversations and transcripts reference them. A retired agent cannot dial. 409 while an ACTIVE inbound route of type ai_agent still names the agent (core#1547): the body names the route(s); disable or re-point them first. Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.

Parameters

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

Responses

200 ​

The retired agent.

FieldTypeRequiredDescription
ai_agentobject (AiAgent)yes
ai_agent.idstring (uuid)yes
ai_agent.namestringyes
ai_agent.statusdraft · live · paused · retiredyes
ai_agent.voiceobject (AiVoice)no
ai_agent.voice.languagestringyesBCP-47 (e.g. en-US).
ai_agent.voice.voice_idstringyesProvider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it.
ai_agent.voice.pacenumberyes0 = slow … 1 = fast (0.5 ≈ natural).
ai_agent.disclosureobject (AiDisclosure)no
ai_agent.disclosure.textstringyesThe opening artificial/prerecorded-voice disclosure (content-validated, EN/ES).
ai_agent.disclosure.mandatedtrueyesAlways true — the disclosure cannot be disabled.
ai_agent.disclosure.lockedbooleannoTrue once the agent has been live; text is then immutable.
ai_agent.policyobject (AiPolicy)noNegotiation guardrails the AI is bounded by.
ai_agent.policy.max_settlement_pctintegernoMax settlement as a percent of balance.
ai_agent.policy.min_payment_centsintegerno
ai_agent.policy.max_plan_monthsintegerno
ai_agent.policy.take_paymentsnone · card_on_filenocard_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none.
ai_agent.policy.prohibitedarray of stringnoDisallowed conduct/topics (free-form tags).
ai_agent.handoff_rulesarray of AiHandoffRuleno
ai_agent.handoff_rules[].idstring (uuid) | nullno
ai_agent.handoff_rules[].triggerkeywords · negative_sentiment · payment_over · human_requestedyes
ai_agent.handoff_rules[].paramsobjectnoTrigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}.
ai_agent.handoff_rules[].actiontransfer · offer_human · confirm_humanyes
ai_agent.handoff_rules[].severityhard · softyes
ai_agent.handoff_rules[].lockedbooleannohuman_requested is ALWAYS locked (never removable/editable).
ai_agent.policy_versionintegeryesMonotonic; increments on every versioned change. Transcripts cite it.
ai_agent.created_atstring (date-time)no
ai_agent.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

403 — AiAgentWriteForbidden ​

An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
  }
}

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

Sandbox conversation (no live call) ​

http
POST /v1/ai/agents/{id}/test

Drives the agent through a stateless SANDBOX exchange (proxied to the AI runtime) — NO real call, NO ai_conversations row, NO dial. Omit session_id to start; echo it back to continue the same sandbox thread. 503 when the runtime sandbox is unconfigured/unreachable. Management scope: a ddw_ browser token is 403.

Every turn spends the house LLM, so it goes through the inference spend brake (core#2421): message above 4 000 bytes or session_id above 128 bytes is 400; a tenant over its per-window quota of sandbox turns (or over the per-session quota) is 429 with Retry-After; a synthetic demo tenant is 403 (synthetic_tenant); and when the brake refuses the spend (no monthly ceiling, or the ceiling reached) the answer is 503 sandbox_budget_unavailable and the runtime is not called.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
session_idstring (uuid)noOmit to start a new sandbox thread; echo to continue one. At most 128 bytes (400 above).
messagestringyesAt most 4 000 bytes (400 above).
json
{
  "message": "Yes — I can pay $150 on the 27th."
}

Responses

200 ​

The agent's sandbox reply.

FieldTypeRequiredDescription
session_idstring (uuid)yes
replystringyes
json
{
  "session_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
  "reply": "I can set that up: $150 on June 27 using the card on file ending 4417. Shall I confirm?"
}

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 — AiSandboxThrottled ​

This tenant (or this sandbox session) has already used its quota of sandbox turns in the current window (code rate_limited). The runtime was not called. Wait Retry-After seconds and retry.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many sandbox turns; retry later"
  }
}

502 — BadGateway ​

An upstream dependency (the AI runtime sandbox) returned an unexpected/failed response.

json
{
  "error": {
    "code": "bad_gateway",
    "message": "the AI runtime sandbox could not be reached"
  }
}

503 — SandboxUnavailable ​

The AI runtime sandbox is unconfigured or unreachable (fail-closed — the runtime may not exist yet), or the inference spend brake refused the turn (code sandbox_budget_unavailable).

json
{
  "error": {
    "code": "sandbox_unavailable",
    "message": "the AI runtime sandbox is not configured or unreachable"
  }
}

Hear the agent's voice (fixed sample sentence) ​

http
POST /v1/ai/agents/{id}/voice-preview

Renders ONE fixed sentence in the agent's catalogued voice and returns a short-lived signed URL of the audio, so whoever builds the agent hears the voice before a consumer does. The sentence is NOT an input: it is fixed per language (the voice's primary language, en or es), so the render bill is bounded by voices x languages whatever a caller sends. The audio is content-addressed (sha256(text|voice)): the same voice reading the same sentence renders ONCE, for every agent and every tenant; every later request is served from the store (cached: true) with no provider call. POST because a miss spends paid provider time, even though the audio is a pure function of the voice — a prefetch or a crawler never triggers it.

Only a request that has to RENDER spends the per-tenant budget (429, Retry-After); a cached request never does. url is signed at response time and must be played soon (it expires; it is never stored) — request a fresh one rather than keeping it.

Refusals: 404 for an agent that does not exist or belongs to another tenant; 422 when the agent has no voice.voice_id or one outside GET /v1/ai/voices (a live call would use the cell's own voice, so a sample of that id would be a lie) or the voice speaks a language with no sample sentence; 503 with the failing stage in error.reason when the TTS service cannot produce the audio — never an empty audio. Management scope: a ddw_ browser token is 403.

Parameters

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

Responses

200 ​

The sample and where to play it.

FieldTypeRequiredDescription
voice_previewobject (AiVoicePreview)yes
voice_preview.urlstringyesSigned, short-lived URL of the audio; sign-on-read, never stored.
voice_preview.voice_idstringyesThe catalogued voice that read the sentence.
voice_preview.languagestringyesPrimary BCP-47 subtag of the voice (en or es) — it picks the sentence.
voice_preview.textstringyesThe fixed sentence that was read.
voice_preview.cachedbooleanyestrue when the audio already existed (nothing was rendered for this request).
json
{
  "voice_preview": {
    "url": "https://media.example.com/tts/3f9a...c1?X-Signature=...",
    "voice_id": "b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1",
    "language": "en",
    "text": "Hello, this is a preview of the voice your agent will use.",
    "cached": true
  }
}

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

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 — VoicePreviewThrottled ​

This tenant has already caused its quota of voice renders in the current window (code rate_limited). The request rendered nothing. Wait Retry-After seconds and retry; a request for a sample that is already rendered is never counted and never refused by this budget.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many voice samples rendered; retry later"
  }
}

503 — VoicePreviewUnavailable ​

The sample could not be produced right now (code voice_preview_unavailable). error.reason names the failing stage and never carries the provider's body, endpoint or credentials: renderer_unconfigured, renderer_unreachable, renderer_http_<status>, renderer_empty_audio, render_failed or store_unavailable. A failed render stores nothing and a retry renders again; after store_unavailable the audio is already stored and a retry is a cache hit.

json
{
  "error": {
    "code": "voice_preview_unavailable",
    "message": "the voice sample could not be produced right now",
    "reason": "renderer_http_502"
  }
}

A/B experiments of an AI agent ​

http
GET /v1/ai/agents/{id}/experiments

Every experiment of the agent (any state), newest first, each with its variants in position order.

Parameters

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

Responses

200 ​

The agent's experiments.

FieldTypeRequiredDescription
experimentsarray of AiAgentExperimentyes
experiments[].idstring (uuid)no
experiments[].ai_agent_idstring (uuid)no
experiments[].namestringno
experiments[].statedraft · running · endedno
experiments[].primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiments[].started_atstring,null (date-time)no
experiments[].ended_atstring,null (date-time)no
experiments[].ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiments[].variantsarray of AiAgentExperimentVariantno
experiments[].variants[].idstring (uuid)no
experiments[].variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiments[].variants[].namestringno
experiments[].variants[].is_controlbooleanno
experiments[].variants[].weightintegerno
experiments[].variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiments[].variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiments[].variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiments[].variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiments[].variants[].overrides.llmobjectnoAt least one of the two.
experiments[].created_atstring (date-time)no
experiments[].updated_atstring (date-time)no

401 — Unauthorized ​

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

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

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

Create an A/B experiment of an AI agent (draft) ​

http
POST /v1/ai/agents/{id}/experiments

Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.

Creates a draft experiment of 2 to 5 variants with exactly one control, over a live agent (any other agent is 409). A variant may override only the CLOSED list voice (a voice_id of the voice catalog and/or a pace in [0, 1]), prompt_style (one line of up to 600 characters, appended AFTER everything the agent says by obligation), model (a model of the model catalog) and llm (reasoning_effort, prompt_form). Any other key (disclosure, policy, handoff_rules, tools, max_settlement_pct...) is refused: the legal notice, the negotiation limits and the handoff are not something to test. Every refused field comes back in ONE 422, each with its path in error.details. While the experiment runs, agent-config carries the variant of each call and the conversation records it; an agent with no experiment behaves as before.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
variantsarray of AiAgentExperimentVariantWriteno
variants[].namestringyes
variants[].weightintegeryesShare of the traffic, relative to the sum of the weights.
variants[].is_controlbooleannoExactly one variant of the experiment is the control (the base agent, so it sends no overrides).
variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
variants[].overrides.voice.voice_idstringno
variants[].overrides.voice.pacenumberno
variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
variants[].overrides.llmobjectnoAt least one of the two.
variants[].overrides.llm.reasoning_effortlow · medium · highno
variants[].overrides.llm.prompt_formcompact · fullno

Responses

201 ​

The draft experiment.

FieldTypeRequiredDescription
experimentobject (AiAgentExperiment)yes
experiment.idstring (uuid)no
experiment.ai_agent_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiment.variantsarray of AiAgentExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiment.variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiment.variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiment.variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiment.variants[].overrides.llmobjectnoAt least one of the two.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

400 — BadRequest ​

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

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

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

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

403 — Forbidden ​

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

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

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

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

409 — Conflict ​

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

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

422 — AiAgentExperimentUnprocessable ​

The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.

json
{
  "error": {
    "code": "unprocessable",
    "message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
    "details": [
      {
        "path": "variants[1].overrides",
        "message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
      }
    ]
  }
}

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 an A/B experiment of an AI agent ​

http
GET /v1/ai/agents/{id}/experiments/{experiment_id}

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Responses

200 ​

The experiment with its variants.

FieldTypeRequiredDescription
experimentobject (AiAgentExperiment)yes
experiment.idstring (uuid)no
experiment.ai_agent_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiment.variantsarray of AiAgentExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiment.variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiment.variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiment.variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiment.variants[].overrides.llmobjectnoAt least one of the two.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

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

Edit a draft A/B experiment of an AI agent ​

http
PATCH /v1/ai/agents/{id}/experiments/{experiment_id}

Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.

name and/or variants (which REPLACES the whole set); primary_metric may only be repeated, never changed. Only a draft can be edited: once it runs (or ended) its variants are frozen and this is 409 (to test another prompt, voice or model, create another experiment).

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
variantsarray of AiAgentExperimentVariantWriteno
variants[].namestringyes
variants[].weightintegeryesShare of the traffic, relative to the sum of the weights.
variants[].is_controlbooleannoExactly one variant of the experiment is the control (the base agent, so it sends no overrides).
variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
variants[].overrides.voice.voice_idstringno
variants[].overrides.voice.pacenumberno
variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
variants[].overrides.llmobjectnoAt least one of the two.
variants[].overrides.llm.reasoning_effortlow · medium · highno
variants[].overrides.llm.prompt_formcompact · fullno

Responses

200 ​

The edited experiment.

FieldTypeRequiredDescription
experimentobject (AiAgentExperiment)yes
experiment.idstring (uuid)no
experiment.ai_agent_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiment.variantsarray of AiAgentExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiment.variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiment.variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiment.variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiment.variants[].overrides.llmobjectnoAt least one of the two.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

400 — BadRequest ​

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

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401 — Unauthorized ​

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

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

403 — Forbidden ​

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

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

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

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

409 — Conflict ​

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

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

422 — AiAgentExperimentUnprocessable ​

The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.

json
{
  "error": {
    "code": "unprocessable",
    "message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
    "details": [
      {
        "path": "variants[1].overrides",
        "message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
      }
    ]
  }
}

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

Delete a draft A/B experiment of an AI agent ​

http
DELETE /v1/ai/agents/{id}/experiments/{experiment_id}

Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.

Only a draft can be deleted; a running or ended experiment is 409.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Responses

204 ​

Deleted.

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

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

Results and honest verdict of an A/B experiment of an AI agent ​

http
GET /v1/ai/agents/{id}/experiments/{experiment_id}/results

Recomputed from the ledger on every read (no counters): the conversations stamped with the experiment (ai_conversations), their per-call metrics (ai_call_metrics) and, for the unit of randomization, the debt of the call attempt. A valid conversation is a closed one whose outcome is neither failed nor voicemail; the rates of the primary metrics are over valid conversations, failed_rate over all closed ones. The same experiment read twice gives the same answer.

verdict.status is insufficient_data (some arm is under 100 valid conversations or 10 successes for a rate, under 100 calls with the measure for a mean), no_significant_difference or winner. Rates use the two-proportion z test, means (latency, cost) Welch's test in the normal approximation; two-sided, alpha 0.05, Bonferroni over the challengers. Which way is better depends on the metric: ptp_rate and contained_rate more, handoff_rate, first_audio_p95_ms and cost_per_call less. When the observed split of units deviates from the weights (chi-square, alpha 0.001) allocation_warning is true and no winner is emitted.

A cost with no rate loaded is unpriced (never 0): cost.status says priced, partial or unpriced per variant, and when the primary metric is cost_per_call and some arm has an unpriced call the verdict is insufficient_data with reason: unpriced. The amount of the cost is never published (it is the platform's provider cost; the tenant is billed per minute): each variant carries its consumption in minutes, and a cost_per_call verdict keeps its significance and its winner but not the magnitudes (value, difference, ci_low, ci_high).

safety.breaches is the evidence of the compliance stop: a variant whose compliance_violation_rate is significantly higher than the control's (z, alpha 0.05, at least 100 closed conversations in both arms). Core ends the experiment on its own when that happens (ended_reason says why) and the agent goes back to its base configuration. Reading never changes the experiment.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Responses

200 ​

The results of the experiment.

FieldTypeRequiredDescription
resultsobject (AiAgentExperimentResults)yes
results.experimentobjectyes
results.experiment.idstring (uuid)no
results.experiment.ai_agent_idstring (uuid)no
results.experiment.namestringno
results.experiment.statedraft · running · endedno
results.experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
results.experiment.started_atstring,null (date-time)no
results.experiment.ended_atstring,null (date-time)no
results.experiment.ended_reasonstring,nullnoWhy it ended when something other than a manual stop ended it (the compliance stop writes its evidence here).
results.variantsarray of AiAgentExperimentVariantResultsyes
results.variants[].variant_idstring (uuid)no
results.variants[].namestringno
results.variants[].is_controlbooleanno
results.variants[].weightintegerno
results.variants[].assigned_unitsintegernoDistinct units with a conversation in this variant: the debt when the call has one, else the call.
results.variants[].conversationsintegerno
results.variants[].closedintegernoConversations with their terminal outcome.
results.variants[].validintegernoClosed with an outcome other than failed and voicemail; the denominator of the rates.
results.variants[].failedintegerno
results.variants[].ptpintegernoValid conversations that ended ptp or payment_taken.
results.variants[].handoffsintegernoValid conversations a human took over.
results.variants[].containedintegernoValid conversations with no human handoff and an outcome that is neither handoff nor handoff_failed.
results.variants[].violatingintegernoClosed conversations with at least one compliance violation.
results.variants[].ptp_ratenumber,nullno
results.variants[].contained_ratenumber,nullno
results.variants[].handoff_ratenumber,nullnoBetter is LESS.
results.variants[].failed_ratenumber,nullnoOver all closed conversations.
results.variants[].compliance_violation_ratenumber,nullnoOver all closed conversations.
results.variants[].provider_error_ratenumber,nullnoClosed conversations with some provider error over the closed ones that have metrics; null when none has.
results.variants[].metrics_coverageobjectnoHow many closed conversations published metrics. A runtime that does not publish yet shows here, not as a clean 0.
results.variants[].metrics_coverage.with_metricsintegerno
results.variants[].metrics_coverage.closedintegerno
results.variants[].consumptionobjectnoWhat the closed calls consumed, in the unit the tenant is billed in (minutes). Both fields are null when no closed call has a duration.
results.variants[].consumption.minutes_totalnumber,nullno
results.variants[].consumption.minutes_per_callnumber,nullno
results.variants[].first_audio_ms_p95objectnoThe per-call p95 of the first-audio latency.
results.variants[].first_audio_ms_p95.nintegerno
results.variants[].first_audio_ms_p95.meannumber,nullno
results.variants[].first_audio_ms_p95.variancenumber,nullno
results.variants[].first_audio_ms_p95.p50_meannumber,nullnoMean of the per-call p50.
results.variants[].first_audio_ms_p95.p95number,nullno95th percentile of the per-call p95.
results.variants[].costobjectnoWhether the provider cost of the calls is known. The amount is never published: it is the platform's cost, not a price (the tenant is billed per minute).
results.variants[].cost.statuspriced · partial · unpricednounpriced when no call with metrics has a frozen cost: it is never shown as 0.
results.variants[].cost.nintegernoCalls with a frozen cost.
results.variants[].cost.unpriced_callsintegerno
results.unattributed_conversationsintegeryesConversations stamped with the experiment but with a variant it does not have. Non-zero means the stamp and the experiment disagree.
results.verdictobject (AiAgentExperimentVerdict)yes
results.verdict.statusinsufficient_data · no_significant_difference · winneryes
results.verdict.reasonunpriced · nullyesWhy a verdict that could be read is insufficient_data for a reason other than sample size.
results.verdict.winnernull | objectyes
results.verdict.withheld_bynull | allocation_warningyes
results.verdict.comparisonsarray of objectyesOne entry per challenger; empty while the sample floor is not met.
results.verdict.comparisons[].variant_idstring (uuid)no
results.verdict.comparisons[].valuenumbernoThe challenger's rate or mean. Absent when the primary metric is cost_per_call.
results.verdict.comparisons[].differencenumbernoChallenger minus control. Absent when the primary metric is cost_per_call.
results.verdict.comparisons[].znumber,nullnoNull when both arms have no variance and different means.
results.verdict.comparisons[].p_valuenumberno
results.verdict.comparisons[].adjusted_p_valuenumberno
results.verdict.comparisons[].ci_lownumbernoAbsent when the primary metric is cost_per_call.
results.verdict.comparisons[].ci_highnumbernoAbsent when the primary metric is cost_per_call.
results.verdict.comparisons[].significantbooleanno
results.verdict.sample_floorobjectyes
results.verdict.sample_floor.min_nintegerno
results.verdict.sample_floor.min_successesinteger,nullnoNull for a mean.
results.verdict.sample_floor.metbooleanno
results.allocation_warningbooleanyesThe observed split of assigned units deviates from the weights (chi-square, alpha 0.001). A winner is never emitted while it is true.
results.allocationobjectyes
results.allocation.chi_squarenumberno
results.allocation.degrees_of_freedomintegerno
results.allocation.p_valuenumberno
results.allocation.warningbooleanno
results.safetyobjectyes
results.safety.breachesarray of objectyesVariants whose compliance violation rate is significantly higher than the control's. Empty when there is none or when an arm has fewer than 100 closed conversations.
results.safety.breaches[].variant_idstring (uuid)no
results.safety.breaches[].namestringno
results.safety.breaches[].control_ratenumber,nullno
results.safety.breaches[].ratenumber,nullno
results.safety.breaches[].control_closedintegerno
results.safety.breaches[].closedintegerno
results.safety.breaches[].znumber,nullno
results.safety.breaches[].p_valuenumberno
results.safety.breaches[].diffnumberno

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

Start an A/B experiment of an AI agent ​

http
POST /v1/ai/agents/{id}/experiments/{experiment_id}/start

Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.

draft → running. The agent must be live and the variants are validated again against the voice and model catalogs as they are now (422 with details if a model left the catalog since the draft was written). An agent with another experiment running, an agent that is not live, or an experiment that is not a draft, is 409.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Responses

200 ​

The experiment, now running.

FieldTypeRequiredDescription
experimentobject (AiAgentExperiment)yes
experiment.idstring (uuid)no
experiment.ai_agent_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiment.variantsarray of AiAgentExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiment.variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiment.variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiment.variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiment.variants[].overrides.llmobjectnoAt least one of the two.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

403 — Forbidden ​

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

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

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

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

409 — Conflict ​

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

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

422 — AiAgentExperimentUnprocessable ​

The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.

json
{
  "error": {
    "code": "unprocessable",
    "message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
    "details": [
      {
        "path": "variants[1].overrides",
        "message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
      }
    ]
  }
}

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

End an A/B experiment of an AI agent ​

http
POST /v1/ai/agents/{id}/experiments/{experiment_id}/stop

Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.

running → ended (terminal); the agent goes back to being answered as the base agent. Anything else → 409.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId of an A/B experiment of the AI agent.

Responses

200 ​

The experiment, now ended.

FieldTypeRequiredDescription
experimentobject (AiAgentExperiment)yes
experiment.idstring (uuid)no
experiment.ai_agent_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metricptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_callno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.ended_reasonstring,nullnoWhy it ended, when something other than a manual stop ended it; null for a manual stop.
experiment.variantsarray of AiAgentExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder the variants are walked in to assign a debt (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (AiAgentExperimentVariantOverrides)noWhat a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases.
experiment.variants[].overrides.voiceobjectnoAt least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing.
experiment.variants[].overrides.prompt_stylestringnoOne line (no line break), appended after everything the agent composes; it cannot replace any of it.
experiment.variants[].overrides.modelopenai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instantnoA model of the platform's model catalog.
experiment.variants[].overrides.llmobjectnoAt least one of the two.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

403 — Forbidden ​

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

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

404 — NotFound ​

Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).

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

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

List AI conversations (live feed or history) ​

http
GET /v1/ai/conversations

active=true returns the LIVE floor (conversations still in progress), newest first, UNPAGINATED. Without it, returns closed conversations, keyset-paginated (from/to bound started_at).

Parameters

NameInTypeRequiredDescription
activequerybooleannotrue → only in-progress conversations (the live feed).
agent_idquerystring (uuid)noRestrict to conversations handled by this AI agent.
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
limitqueryintegernoPage size (default 100, max 1000).
cursorquerystringnoOpaque keyset cursor from the previous page's next_cursor.

Responses

200 ​

A page/feed of conversations.

FieldTypeRequiredDescription
ai_conversationsarray of AiConversationListItemyes
ai_conversations[].idstring (uuid)yes
ai_conversations[].call_idstring | nullnoThe call's FreeSWITCH uuid.
ai_conversations[].ai_agent_idstring (uuid) | nullno
ai_conversations[].account_refstring | nullno
ai_conversations[].us_statestring | nullno
ai_conversations[].phasedisclosure · verification · negotiation · payment_setup · wrapupyes
ai_conversations[].duration_secsinteger | nullno
ai_conversations[].sentimentcalm · positive · frustrated · negative | nullno
ai_conversations[].handoff_suggestedbooleanno
ai_conversations[].started_atstring (date-time)no
ai_conversations[].ended_atstring (date-time) | nullno
next_cursorstring | nullyesOpaque keyset cursor; null on the last page and for the active=true feed.

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

Fetch a conversation with transcript and outcome ​

http
GET /v1/ai/conversations/{id}

PII (core#2460, ADR #240). Each transcript text comes out with the closed PII catalog redacted (a payment card number, an SSN, a labelled bank account number or a PIN/password), keeping at most the last four digits. include_pii=true asks for the original: it takes a dd_ key or a dashboard login with the account_admin role (403 role_forbidden otherwise, never a silently redacted answer) and is audited.

Parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
include_piiquerybooleannotrue returns the transcript WITHOUT PII redaction. Requires a dd_ key or an account_admin login: 403 role_forbidden for anyone else. Absent or false ⇒ redacted. Anything that is not a boolean ⇒ 400.

Responses

200 ​

The conversation, its full transcript and its outcome.

FieldTypeRequiredDescription
ai_conversationanyyes

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

Supervisor takeover of a live AI conversation ​

http
POST /v1/ai/conversations/{id}/takeover

Transfers the debtor leg from the AI to the supervisor's REGISTERED endpoint and tears down the orphaned AI leg — the SAME evidence-first mechanic as POST /v1/supervision/calls/{call_id}/takeover (a durable supervision_actions row is written BEFORE any switch command). The {id} is the CONVERSATION id; the server resolves its call_id and drives Dialer.Supervision. A conversation with no live call (sandbox/never-dialed) is 409. session_id in the result is null (no eavesdrop leg). Same WHO gates as the supervision operations: supervisor_ext must carry role supervisor|admin (ADR #116), and the principal must be allowed to act as that agent (ADR #135: a seated ddu_ only as its own seat — 403 forbidden; a seatless ddu_ naming a third party only as account_admin once ddu_author_role_enforced is on — 403 role_forbidden); every refusal is evidenced as refused.

Parameters

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

Request body (JSON, required)

FieldTypeRequiredDescription
supervisor_extstringyesMust be an ACTIVE roster extension of THIS tenant with role supervisor or admin (ADR #116; 403 role_forbidden otherwise), and one the principal may act as (ADR #135; a seated ddu_ only its own seat, 403 forbidden); their checked-in device receives the supervision leg (answer within 20 s).
json
{
  "supervisor_ext": "2000"
}

Responses

200 ​

Debtor leg transferred to the supervisor (audit row durable).

FieldTypeRequiredDescription
supervisionobject (SupervisionResult)yes
supervision.actionlisten · whisper · barge · takeoverno
supervision.call_idstringno
supervision.supervisor_extstringno
supervision.session_idstring | nullnoSupervisor leg uuid; null for takeover (no eavesdrop leg).
supervision.audit_idstring (uuid)noThe durable supervision_actions row.
supervision.to_numberstringnoMasked last-4 (***0184) — the full E.164 never crosses this surface.

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 — SupervisionRoleForbidden ​

The caller may not supervise with that supervisor_ext (ADR #116, core#928; ADR #135, core#1037 phase 3). code role_forbidden: the extension resolves to an active agent whose role is not supervisor or admin — or a ddu_ login without a linked seat named somebody else's extension without the account_admin role (once ddu_author_role_enforced is on). codeforbidden: a principal bound to ONE agent — a ddw_ token, or a ddu_ login linked to a seat — named an extension that is not its own. Either way the attempt IS evidenced — a supervision_actions row with result: refused — and ZERO switch commands ran. Per tenant, supervision_role_enforced = false turns the extension's role check off (the identity binding and the principal rule stay).

role_forbidden

json
{
  "error": {
    "code": "role_forbidden",
    "message": "supervisor_ext must belong to an agent with role supervisor or admin"
  }
}

forbidden

json
{
  "error": {
    "code": "forbidden",
    "message": "supervisor_ext must be the agent bound to this token"
  }
}

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

502 — SupervisionFailed ​

The switch rejected the supervision command (call already ended / supervisor device not registered).

json
{
  "error": {
    "code": "supervision_failed",
    "message": "the switch rejected the supervision command"
  }
}

503 — SupervisionUnavailable ​

code audit_unavailable: the evidence row could not be written so NO switch command was sent (evidence-first, fail-closed). codeesl_unavailable: switch control link down. Both retryable.

audit_unavailable

json
{
  "error": {
    "code": "audit_unavailable",
    "message": "supervision audit unavailable, retry"
  }
}

esl_unavailable

json
{
  "error": {
    "code": "esl_unavailable",
    "message": "telephony control unavailable, retry"
  }
}

Kill-switch — pause all AI dialing (tenant-wide) ​

http
POST /v1/ai/pause

Idempotent. Immediately stops the tenant's AI agents from originating new calls (the "Pause all AI dialing" control; the AIDisclosure gate then blocks every AI origination fail-closed). In-flight conversations continue; use takeover to intervene. The toggle is stamped (paused_at, paused_by). Management scope: a ddw_ browser token is 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).

Responses

200 ​

AI dialing is paused.

FieldTypeRequiredDescription
ai_dialingactive · pausedyes
paused_atstring (date-time) | nullno
paused_bystring | nullno
json
{
  "ai_dialing": "paused",
  "paused_at": "2026-07-09T17:40: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"
  }
}

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

Kill-switch — resume AI dialing (tenant-wide) ​

http
POST /v1/ai/resume

Idempotent inverse of POST /v1/ai/pause. Management scope (403 for ddw_).

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

Responses

200 ​

AI dialing is active.

FieldTypeRequiredDescription
ai_dialingactive · pausedyes
paused_atstring (date-time) | nullno
paused_bystring | nullno
json
{
  "ai_dialing": "active",
  "paused_at": null
}

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

AI-dialing kill-switch state ​

http
GET /v1/ai/status

The current kill-switch state (same shape as pause/resume).

Responses

200 ​

The kill-switch state.

FieldTypeRequiredDescription
ai_dialingactive · pausedyes
paused_atstring (date-time) | nullno
paused_bystring | nullno
json
{
  "ai_dialing": "active",
  "paused_at": null
}

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

Today's AI operating metrics (UTC day) ​

http
GET /v1/ai/metrics/today

Aggregate for the current UTC day off ai_conversations + outcomes + the durable ai_usage meter (the same billable feed as /v1/stats/ai-usage).

Responses

200 ​

Today's metrics.

FieldTypeRequiredDescription
datestring (date)no
handledintegernoAI-handled conversations today, settled or not.
unsettledintegernoConversations started today with no terminal outcome yet (outcome_type null: still in flight, or their close never reached core). Counted in handled, never in containment_rate.
containment_ratenumber | nullnoShare of today's settled conversations WITH A PERSON that the AI resolved on its own (0..1), rounded to 4 decimals (core#1121, ADR #147). Numerator: outcome_type in resolved, ptp, payment_taken, callback, refused. Denominator: the numerator plus failed, handoff, handoff_failed and dispute. In neither: voicemail (no person on the line; see voicemail_drops) and the unsettled conversations. A conversation handed to a human (the runtime reported handoff.occurred) is never contained, whatever its outcome_type: a ptp or payment_taken followed by a handoff counts in the denominator only (core#1131, ADR #149). null when containment_reason is set.
containment_reasonno_settled_conversations | nullnoWhy containment_rate is null: no_settled_conversations (no conversation with a person settled today — the denominator is 0; absence, not a zero). null when containment_rate is a number.
avg_handle_secsintegerno
handled_delta_pctnumber | nullnoPercent change in handled vs. yesterday up to the SAME time-of-day; null when yesterday had none.
handoffsobjectno
handoffs.countintegerno
handoffs.pctnumberno
handoffs_failedobjectnoConversations settled handoff_failed today: a human was requested and the handoff was not ordered. Counted neither in handoffs nor in containment.
handoffs_failed.countintegerno
handoffs_failed.pctnumberno
ai_minutesnumbernoBillable AI minutes today (from the ai_usage meter).
billed_centsintegerno
ptpobjectno
ptp.countintegerno
ptp.amount_centsintegerno
paymentsobjectno
payments.countintegerno
payments.amount_centsintegerno
voicemail_dropsintegernoConversations the runtime classified voicemail (its AMD verdict) today.
compliance_violationsintegerno

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

Per-campaign artificial-voice eligibility ​

http
GET /v1/ai/eligibility

One entry per campaign that PLAYS artificial voice (AI or TTS/IVR/survey), each with whether it may originate now given the tenant's AI-disclosure capability and the AI-dialing kill-switch. Per-recipient consent (the other half of the artificial-voice gate) is enforced at dial time — not summarized here.

Responses

200 ​

The eligibility list.

FieldTypeRequiredDescription
eligibilityarray of AiEligibilityItemyes
eligibility[].campaign_idstring (uuid)yes
eligibility[].namestringyes
eligibility[].eligiblebooleanyes
eligibility[].reasonstring | nullnoWhy it cannot originate (ai_dialing_paused / disclosure_missing); null when eligible.

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

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