Appearance
Campaigns
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Campaign CRUD, lifecycle, manual dial, rewind.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/campaigns | List campaigns |
POST | /v1/campaigns | Create a campaign (state draft) |
GET | /v1/campaigns/{id} | Fetch a campaign |
PATCH | /v1/campaigns/{id} | Update campaign config (never state) |
POST | /v1/campaigns/{id}/start | Start a campaign |
POST | /v1/campaigns/{id}/resume | Resume a paused campaign (alias of start) |
POST | /v1/campaigns/{id}/pause | Pause a running campaign |
POST | /v1/campaigns/{id}/stop | Stop a campaign (terminal completed) |
POST | /v1/campaigns/{id}/archive | Archive a campaign |
GET | /v1/campaigns/{id}/experiments | A/B experiments of a campaign |
POST | /v1/campaigns/{id}/experiments | Create an A/B experiment (draft) |
GET | /v1/campaigns/{id}/experiments/{experiment_id} | Fetch an A/B experiment |
PATCH | /v1/campaigns/{id}/experiments/{experiment_id} | Edit a draft A/B experiment |
DELETE | /v1/campaigns/{id}/experiments/{experiment_id} | Delete a draft A/B experiment |
GET | /v1/campaigns/{id}/experiments/{experiment_id}/results | Results and verdict of an A/B experiment |
POST | /v1/campaigns/{id}/experiments/{experiment_id}/start | Start an A/B experiment |
POST | /v1/campaigns/{id}/experiments/{experiment_id}/stop | End an A/B experiment |
POST | /v1/campaigns/{id}/dial | Human-initiated dial (preview/manual campaigns) |
POST | /v1/dial/manual | Dial a typed number (dialpad), resolved to one of the tenant's accounts |
GET | /v1/campaigns/{id}/next-lead | Preview the next dialable lead (without dialing) |
GET | /v1/campaigns/{id}/compliance-preflight | Compliance preflight (dry-run) of a campaign's dialable leads |
GET | /v1/campaigns/{id}/stats | Live runner + pacing counters |
GET | /v1/campaigns/{id}/survey-results | Survey / IVR keypress distribution |
GET | /v1/campaigns/{id}/abandonment | Durable abandonment rate per 30-day period |
GET | /v1/campaigns/{id}/email-skips | Why an e-mail campaign did not send to a lead |
GET | /v1/campaigns/{id}/rewind/preview | Rewind dry-run (filters in query) |
POST | /v1/campaigns/{id}/rewind/preview | Rewind dry-run (filters in body) |
POST | /v1/campaigns/{id}/rewind | Re-queue matching exhausted debts |
List campaigns
http
GET /v1/campaignsResponses
200
All campaigns of the tenant.
| Field | Type | Required | Description |
|---|---|---|---|
campaigns | array of Campaign | yes | |
campaigns[].id | string (uuid) | no | |
campaigns[].tenant_id | string (uuid) | no | |
campaigns[].name | string | no | |
campaigns[].state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaigns[].campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaigns[].channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaigns[].sms_body | string | null | no | |
campaigns[].email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaigns[].email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaigns[].abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaigns[].min_dial_ratio | string | null | no | |
campaigns[].max_dial_ratio | string | null | no | |
campaigns[].calling_window_start | string | null | no | HH:MM:SS. |
campaigns[].calling_window_end | string | null | no | |
campaigns[].default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaigns[].calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaigns[].calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaigns[].starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaigns[].ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaigns[].record_calls | boolean | no | |
campaigns[].ai_voice | boolean | no | |
campaigns[].amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaigns[].optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaigns[].tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaigns[].voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaigns[].voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaigns[].ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaigns[].deferred_bridge | boolean | no | |
campaigns[].consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaigns[].carrier | string | null | no | |
campaigns[].caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaigns[].caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaigns[].created_at | string (date-time) | no | |
campaigns[].updated_at | string (date-time) | no | |
truncated | boolean | yes | true when there are more rows than the 2 000 campaigns the listing returns: the response is a prefix in the documented order, not the whole set. |
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 a campaign (state draft)
http
POST /v1/campaignscampaign_type selects the pacing profile (predictive default; preview/manual are the human-initiated modes POST /v1/campaigns/{id}/dial requires; sms_blast is the :sms-channel type; email_blast the :email-channel type, which sends an approved email template: email_template_id is REQUIRED and email_template_variables carries its static values). sms_body is REQUIRED for sms_blast and must contain the STOP opt-out notice (Reg F §1006.6(e)); it supports {{debt_ref}}, {{consumer_ref}}, {{account_number}}, {{amount}}, {{state}} merge vars. Every id in caller_id_pool must exist in the tenant's /v1/dids registry (foreign ids are indistinguishable from unknown → 400 invalid). Duplicate name → 409 conflict.
A campaign that runs WITHOUT an agent (tts_blast, tts_ivr, ivr_cascade, survey, ai_voice, sms_blast, email_blast) is refused with 403verification_required while the account is a 1-seat account that has not been verified (macro-tasks#870, point 7). Campaigns with an agent are not affected.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
sms_body | string | no | REQUIRED for sms_blast; must contain the STOP opt-out notice; supports merge vars. |
email_template_id | string (uuid) | no | REQUIRED for email_blast: an APPROVED email template version of this account (not a template of this account / must be an approved template → 400 invalid). |
email_template_variables | object | no | Static values (string or number) for the variables the template declares; validated against its declaration at write time (400 invalid). |
abandonment_threshold | number | no | |
min_dial_ratio | number | no | |
max_dial_ratio | number | no | |
calling_window_start | string | no | HH:MM:SS. |
calling_window_end | string | no | |
default_timezone | string | no | |
starts_at | string | null | no | The calendar window's start (R8, core#1501): a WALL-CLOCK instant such as 2026-11-02T09:00:00, read in the campaign's default_timezone. A UTC offset or Z is refused (400 invalid): the value has no zone of its own, so it cannot disagree with the campaign's. While the tenant has the campaign_window_enabled flag on, a campaign still in draft is started by the platform once that local time is reached (an append-only campaign_state_events row records it). A campaign a human has paused or the platform has exhausted is never restarted by it. null clears it. Without the flag, or without a value, nothing changes: the campaign starts only when POST /v1/campaigns/{id}/start says so. |
ends_at | string | null | no | The calendar window's end: same format and zone as starts_at, and later than it when both are set. While the flag is on, a running campaign is completed by the platform once that local time is reached. completed is terminal, so a campaign a human starts after its ends_at is completed again on the next pass: clear ends_at first. null clears it. |
calling_window_destination_local | boolean | no | |
calling_window_weekly | CampaignWeeklyWindow | null | no | One window per weekday, read in the SAME zone and on the SAME clock as calling_window_start/_end (default_timezone, or each destination zone with calling_window_destination_local). It only NARROWS: it is ANDed with the single window and with the legal quiet hours, which it never widens. A day left out is CLOSED. Sending it REPLACES the whole weekly window; null removes it. A value the dialer could not read is refused with 422 unprocessable and nothing is stored: an unknown or repeated day, an empty object, a day without exactly start and end, a time that is not HH:MM or HH:MM:SS (an offset such as Z or +05:00, a fraction of a second), start not before end, or a window reaching past the day — a day's window does not cross midnight, and the day ends at 23:59:59 (24:00 is not a time of day). This is a tenant preference, not a legal day-of-week or holiday rule. |
record_calls | boolean | no | |
ai_voice | boolean | no | |
amd_enabled | boolean | null | no | Per-campaign Answering Machine Detection toggle; null (the default) inherits the type profile. A detected machine gets the composed voicemail only when voicemail_optout_number and voicemail_message are complete (see there); otherwise it is hung up on. |
optout_digit | string | no | Exactly ONE DTMF key: pressing it adds the number to the tenant DNC and ends the call. Anything else is 400 invalid naming the field. "" clears it. |
tts_prompt | string | no | Script rendered to audio for the tts_blast/tts_ivr treatments (one render per distinct text). Capped at 2000 characters — several minutes of speech — so the renderer is never handed an unbounded document. "" clears it. |
voicemail_optout_number | string | no | The toll-free number the voicemail gives so the consumer can call back and opt out (47 CFR §64.1200(b)(3)). Must be a NANP toll-free number in E.164 AND an ACTIVE optout inbound route of this tenant (POST /v1/inbound-routes with dest_type: "optout"): calling it records the caller on this tenant's do-not-call list. Anything else is 400 invalid naming the field, and nothing is stored. The dialer re-checks the route on every tick: disabling it silences the voicemail. "" clears it. |
voicemail_message | string | no | The text left on an answering machine (AMD machine verdict) by a tts_blast, tts_ivr, ivr_cascade or survey campaign; every other type hangs up on a machine. Must contain the {optout_number} marker where the number is spoken (400 invalid otherwise). What plays is COMPOSED by the dialer, never a file: the tenant's stored artificial-voice disclosure (its {optout_digit} replaced by this campaign's key), a space, then this message with the marker replaced by the number read digit by digit (8 0 0, 5 5 5, 0 1 2 3), rendered like the prompt. With any piece missing — AMD off, no playable prompt, no number, no marker, the route not active — no voicemail is left and a machine is hung up on. "" clears it. |
ivr_menu | object | no | Digit → action map dispatched on a keypress, flat ({"1": "transfer_agent", "9": "optout"}) or nested ({"prompt": "...", "entries": {"1": {"action": "hangup"}, "2": {"submenu": {...}}}}). Keys are single DTMF keys; actions are transfer_agent, optout, hangup; depth ≤ 5, ≤ 64 nodes. A menu that does not validate is 400 invalid with the engine's reason (Dialer.IVR.Menu.validate/1) — it is never stored. {} = no menu. |
deferred_bridge | boolean | no | Agent campaigns: park the consumer on answer and bridge to the agent only after a HUMAN AMD verdict. Effective only while AMD is on for the campaign. |
consult_config | object | no | Consult-line IVR config, CLOSED schema: factor (one of account_last4, ssn_last4, dob, pin, zip), optional max_attempts (1..10), optional expose (subset of balance, account_status, minimum_payment, due_date, last4_account), optional factor_opts (length positive integer, dob_format mmdd|mmddyyyy). Any other key is 400 invalid naming it. {} = disabled. |
carrier | string | no | |
caller_ids | array of string | no | |
caller_id_pool | array of string (uuid) | no |
json
{
"name": "postman-golden-1751600000",
"default_timezone": "America/Chicago",
"calling_window_start": "00:00:00",
"calling_window_end": "23:59:59",
"abandonment_threshold": 0.03,
"caller_ids": [
"+13125550199"
]
}Responses
201
Created (state draft).
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
json
{
"campaign": {
"id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"name": "postman-golden-1751600000",
"state": "draft",
"campaign_type": "predictive",
"channel": "voice",
"sms_body": null,
"abandonment_threshold": "0.03",
"min_dial_ratio": "1.0",
"max_dial_ratio": "3.0",
"calling_window_start": "00:00:00",
"calling_window_end": "23:59:59",
"default_timezone": "America/Chicago",
"calling_window_weekly": null,
"record_calls": true,
"ai_voice": false,
"carrier": null,
"caller_ids": [
"+13125550199"
],
"caller_id_pool": [],
"created_at": "2026-07-05T10:00:00Z",
"updated_at": "2026-07-05T10:00:00Z"
}
}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 — VerificationRequired
The account is a 1-seat account that has not been verified, and the campaign runs without an agent (code verification_required). Campaigns with an agent are not affected; the account is verified when the platform stamps its identity check (tenants.kyc_verified_at).
json
{
"error": {
"code": "verification_required",
"message": "this account must be verified before it can create or start a campaign that runs without an agent"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — CampaignWeeklyWindowRefused
calling_window_weekly is malformed or reaches past the day (see CampaignWrite.calling_window_weekly); nothing was stored. Any other field in error is named in the same message.
json
{
"error": {
"code": "unprocessable",
"message": "calling_window_weekly mon needs exactly start and end (HH:MM or HH:MM:SS) with start before end; a day's window does not cross midnight, and the day ends at 23:59:59 (24:00 is not a time of day)"
}
}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 campaign
http
GET /v1/campaigns/{id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The campaign.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 campaign config (never state)
http
PATCH /v1/campaigns/{id}Accepts the same fields as create, and ONLY those: any other key — state included — is refused with 400 bad_request naming it, so a typo can never come back 200 with the field ignored. Lifecycle is explicit via the start/pause/stop/archive endpoints. Changing campaign_type to a type that runs without an agent is refused with 403verification_required on an unverified 1-seat account (same rule as create).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
sms_body | string | no | REQUIRED for sms_blast; must contain the STOP opt-out notice; supports merge vars. |
email_template_id | string (uuid) | no | REQUIRED for email_blast: an APPROVED email template version of this account (not a template of this account / must be an approved template → 400 invalid). |
email_template_variables | object | no | Static values (string or number) for the variables the template declares; validated against its declaration at write time (400 invalid). |
abandonment_threshold | number | no | |
min_dial_ratio | number | no | |
max_dial_ratio | number | no | |
calling_window_start | string | no | HH:MM:SS. |
calling_window_end | string | no | |
default_timezone | string | no | |
starts_at | string | null | no | The calendar window's start (R8, core#1501): a WALL-CLOCK instant such as 2026-11-02T09:00:00, read in the campaign's default_timezone. A UTC offset or Z is refused (400 invalid): the value has no zone of its own, so it cannot disagree with the campaign's. While the tenant has the campaign_window_enabled flag on, a campaign still in draft is started by the platform once that local time is reached (an append-only campaign_state_events row records it). A campaign a human has paused or the platform has exhausted is never restarted by it. null clears it. Without the flag, or without a value, nothing changes: the campaign starts only when POST /v1/campaigns/{id}/start says so. |
ends_at | string | null | no | The calendar window's end: same format and zone as starts_at, and later than it when both are set. While the flag is on, a running campaign is completed by the platform once that local time is reached. completed is terminal, so a campaign a human starts after its ends_at is completed again on the next pass: clear ends_at first. null clears it. |
calling_window_destination_local | boolean | no | |
calling_window_weekly | CampaignWeeklyWindow | null | no | One window per weekday, read in the SAME zone and on the SAME clock as calling_window_start/_end (default_timezone, or each destination zone with calling_window_destination_local). It only NARROWS: it is ANDed with the single window and with the legal quiet hours, which it never widens. A day left out is CLOSED. Sending it REPLACES the whole weekly window; null removes it. A value the dialer could not read is refused with 422 unprocessable and nothing is stored: an unknown or repeated day, an empty object, a day without exactly start and end, a time that is not HH:MM or HH:MM:SS (an offset such as Z or +05:00, a fraction of a second), start not before end, or a window reaching past the day — a day's window does not cross midnight, and the day ends at 23:59:59 (24:00 is not a time of day). This is a tenant preference, not a legal day-of-week or holiday rule. |
record_calls | boolean | no | |
ai_voice | boolean | no | |
amd_enabled | boolean | null | no | Per-campaign Answering Machine Detection toggle; null (the default) inherits the type profile. A detected machine gets the composed voicemail only when voicemail_optout_number and voicemail_message are complete (see there); otherwise it is hung up on. |
optout_digit | string | no | Exactly ONE DTMF key: pressing it adds the number to the tenant DNC and ends the call. Anything else is 400 invalid naming the field. "" clears it. |
tts_prompt | string | no | Script rendered to audio for the tts_blast/tts_ivr treatments (one render per distinct text). Capped at 2000 characters — several minutes of speech — so the renderer is never handed an unbounded document. "" clears it. |
voicemail_optout_number | string | no | The toll-free number the voicemail gives so the consumer can call back and opt out (47 CFR §64.1200(b)(3)). Must be a NANP toll-free number in E.164 AND an ACTIVE optout inbound route of this tenant (POST /v1/inbound-routes with dest_type: "optout"): calling it records the caller on this tenant's do-not-call list. Anything else is 400 invalid naming the field, and nothing is stored. The dialer re-checks the route on every tick: disabling it silences the voicemail. "" clears it. |
voicemail_message | string | no | The text left on an answering machine (AMD machine verdict) by a tts_blast, tts_ivr, ivr_cascade or survey campaign; every other type hangs up on a machine. Must contain the {optout_number} marker where the number is spoken (400 invalid otherwise). What plays is COMPOSED by the dialer, never a file: the tenant's stored artificial-voice disclosure (its {optout_digit} replaced by this campaign's key), a space, then this message with the marker replaced by the number read digit by digit (8 0 0, 5 5 5, 0 1 2 3), rendered like the prompt. With any piece missing — AMD off, no playable prompt, no number, no marker, the route not active — no voicemail is left and a machine is hung up on. "" clears it. |
ivr_menu | object | no | Digit → action map dispatched on a keypress, flat ({"1": "transfer_agent", "9": "optout"}) or nested ({"prompt": "...", "entries": {"1": {"action": "hangup"}, "2": {"submenu": {...}}}}). Keys are single DTMF keys; actions are transfer_agent, optout, hangup; depth ≤ 5, ≤ 64 nodes. A menu that does not validate is 400 invalid with the engine's reason (Dialer.IVR.Menu.validate/1) — it is never stored. {} = no menu. |
deferred_bridge | boolean | no | Agent campaigns: park the consumer on answer and bridge to the agent only after a HUMAN AMD verdict. Effective only while AMD is on for the campaign. |
consult_config | object | no | Consult-line IVR config, CLOSED schema: factor (one of account_last4, ssn_last4, dob, pin, zip), optional max_attempts (1..10), optional expose (subset of balance, account_status, minimum_payment, due_date, last4_account), optional factor_opts (length positive integer, dob_format mmdd|mmddyyyy). Any other key is 400 invalid naming it. {} = disabled. |
carrier | string | no | |
caller_ids | array of string | no | |
caller_id_pool | array of string (uuid) | no |
json
{
"record_calls": false
}Responses
200
Updated campaign.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — VerificationRequired
The account is a 1-seat account that has not been verified, and the campaign runs without an agent (code verification_required). Campaigns with an agent are not affected; the account is verified when the platform stamps its identity check (tenants.kyc_verified_at).
json
{
"error": {
"code": "verification_required",
"message": "this account must be verified before it can create or start a campaign that runs without an agent"
}
}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 — CampaignWeeklyWindowRefused
calling_window_weekly is malformed or reaches past the day (see CampaignWrite.calling_window_weekly); nothing was stored. Any other field in error is named in the same message.
json
{
"error": {
"code": "unprocessable",
"message": "calling_window_weekly mon needs exactly start and end (HH:MM or HH:MM:SS) with start before end; a day's window does not cross midnight, and the day ends at 23:59:59 (24:00 is not a time of day)"
}
}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 a campaign
http
POST /v1/campaigns/{id}/startPersists running FIRST, then spawns the runner for the campaign's channel (voice → dial loop, sms_blast → SMS send loop, email_blast → e-mail send loop; same lifecycle API, distinct registry key spaces). Optional runner opts are validated fail-closed (400 on bad types). Idempotent if already running. Invalid transition → 409. A campaign in exhausted (its feed ran dry, R8) starts again here, back to running. A campaign that runs without an agent is refused with 403 verification_required while the account is an unverified 1-seat account (macro-tasks#870, point 7).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
tick_ms | integer | no | |
batch_size | integer | no | |
msgs_per_tick | integer | no | SMS campaigns — default 1 (≈1 MPS, the long-code-safe 10DLC throughput). |
gateway | string | no | Sofia profile identifier. |
agent_domain | string | no | |
agent_sip_proxy | string | no | "" opts the campaign out of the deployment-wide proxy. |
call_ttl_ms | integer | no | |
blocked_cooldown_ms | integer | no |
json
{
"tick_ms": 1000,
"batch_size": 2,
"gateway": "default"
}Responses
200
The campaign, now running.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — VerificationRequired
The account is a 1-seat account that has not been verified, and the campaign runs without an agent (code verification_required). Campaigns with an agent are not affected; the account is verified when the platform stamps its identity check (tenants.kyc_verified_at).
json
{
"error": {
"code": "verification_required",
"message": "this account must be verified before it can create or start a campaign that runs without an agent"
}
}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"
}
}Resume a paused campaign (alias of start)
http
POST /v1/campaigns/{id}/resumeResume IS start (paused → running) — one semantics, two spellings (docs parity).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
tick_ms | integer | no | |
batch_size | integer | no | |
msgs_per_tick | integer | no | SMS campaigns — default 1 (≈1 MPS, the long-code-safe 10DLC throughput). |
gateway | string | no | Sofia profile identifier. |
agent_domain | string | no | |
agent_sip_proxy | string | no | "" opts the campaign out of the deployment-wide proxy. |
call_ttl_ms | integer | no | |
blocked_cooldown_ms | integer | no |
Responses
200
The campaign, now running.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — VerificationRequired
The account is a 1-seat account that has not been verified, and the campaign runs without an agent (code verification_required). Campaigns with an agent are not affected; the account is verified when the platform stamps its identity check (tenants.kyc_verified_at).
json
{
"error": {
"code": "verification_required",
"message": "this account must be verified before it can create or start a campaign that runs without an agent"
}
}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"
}
}Pause a running campaign
http
POST /v1/campaigns/{id}/pausePersists paused and stops the runner; in-flight calls finish via the CDR pipeline.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The campaign, now paused.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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"
}
}Stop a campaign (terminal completed)
http
POST /v1/campaigns/{id}/stopParameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The campaign, now completed.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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"
}
}Archive a campaign
http
POST /v1/campaigns/{id}/archivedraft|completed → archived (terminal). Anything else → 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The campaign, now archived.
| Field | Type | Required | Description |
|---|---|---|---|
campaign | object (Campaign) | yes | |
campaign.id | string (uuid) | no | |
campaign.tenant_id | string (uuid) | no | |
campaign.name | string | no | |
campaign.state | draft · running · paused · completed · archived · suspended_balance · exhausted | no | |
campaign.campaign_type | predictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blast | no | |
campaign.channel | voice · sms · email | no | Derived from the type profile — which runner the lifecycle dispatches to. |
campaign.sms_body | string | null | no | |
campaign.email_template_id | string (uuid) | null | no | The approved email template version an email_blast campaign sends. |
campaign.email_template_variables | object | no | Static values of the template's declared variables (email_blast). |
campaign.abandonment_threshold | string | null | no | Decimal rendered as a JSON string. |
campaign.min_dial_ratio | string | null | no | |
campaign.max_dial_ratio | string | null | no | |
campaign.calling_window_start | string | null | no | HH:MM:SS. |
campaign.calling_window_end | string | null | no | |
campaign.default_timezone | string | null | no | IANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false. |
campaign.calling_window_destination_local | boolean | no | Read calling_window_start/_end in the DESTINATION's local hour instead of default_timezone. false by default, which is the behaviour every campaign had before this field existed. When true the window must be open in EVERY timezone the destination could be in, and a destination whose zone cannot be resolved does not open it. This is a tenant-defined bound only: it can narrow the LEGAL quiet-hours window, never widen it. |
campaign.calling_window_weekly | CampaignWeeklyWindow | null | no | The per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly. |
campaign.starts_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform starts this campaign if it is still draft; read in default_timezone. null = no scheduled start. See CampaignWrite.starts_at. |
campaign.ends_at | string | null | no | Wall-clock instant, as stored (no offset), when the platform completes this campaign if it is running; read in default_timezone. null = no scheduled end. See CampaignWrite.ends_at. |
campaign.record_calls | boolean | no | |
campaign.ai_voice | boolean | no | |
campaign.amd_enabled | boolean | null | no | The STORED per-campaign Answering Machine Detection toggle. null = inherit the type profile's default (on for blaster/TTS/IVR/AI types, off for agent types). |
campaign.optout_digit | string | null | no | The single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key. |
campaign.tts_prompt | string | null | no | The operator's script for the tts_* treatments; null = no prompt. |
campaign.voicemail_optout_number | string | null | no | The NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number. |
campaign.voicemail_message | string | null | no | The voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message. |
campaign.ivr_menu | object | no | The IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu. |
campaign.deferred_bridge | boolean | no | |
campaign.consult_config | object | no | The consult-line config as written ({} = disabled). See CampaignWrite.consult_config. |
campaign.carrier | string | null | no | |
campaign.caller_ids | array of string | no | Prior round-robin pool of E.164 strings (used when caller_id_pool is empty). |
campaign.caller_id_pool | array of string (uuid) | no | DID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed). |
campaign.created_at | string (date-time) | no | |
campaign.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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"
}
}A/B experiments of a campaign
http
GET /v1/campaigns/{id}/experimentsEvery experiment of the campaign (any state), newest first, each with its variants in position order.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The campaign's experiments.
| Field | Type | Required | Description |
|---|---|---|---|
experiments | array of Experiment | yes | |
experiments[].id | string (uuid) | no | |
experiments[].campaign_id | string (uuid) | no | |
experiments[].name | string | no | |
experiments[].state | draft · running · ended | no | |
experiments[].primary_metric | contact_rate · promise_rate | no | |
experiments[].started_at | string,null (date-time) | no | |
experiments[].ended_at | string,null (date-time) | no | |
experiments[].variants | array of ExperimentVariant | no | |
experiments[].variants[].id | string (uuid) | no | |
experiments[].variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiments[].variants[].name | string | no | |
experiments[].variants[].is_control | boolean | no | |
experiments[].variants[].weight | integer | no | |
experiments[].variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiments[].variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiments[].variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiments[].variants[].overrides.min_gap_seconds | integer | no | |
experiments[].variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiments[].created_at | string (date-time) | no | |
experiments[].updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 (draft)
http
POST /v1/campaigns/{id}/experimentsCreates a draft experiment of 2 to 5 variants with exactly one control. A variant may override only the CLOSED list script, window, min_gap_seconds and email_template_id: any other key (in particular optout_digit, abandonment_threshold and max_dial_ratio) is refused. A script goes through the campaign's own validators (an sms_body without «STOP» is refused) and a window must be a subset of the campaign's. window and min_gap_seconds only apply to a voice campaign, and an SMS or email campaign cannot choose promise_rate (a promise points at a call, not at a message): both are refused. Every refused field comes back in ONE 422, each with its path in error.details.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
primary_metric | contact_rate · promise_rate | no | |
variants | array of ExperimentVariantWrite | no | |
variants[].name | string | yes | |
variants[].weight | integer | yes | Share of the traffic, relative to the sum of the weights. |
variants[].is_control | boolean | no | Exactly one variant of the experiment is the control. |
variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
variants[].overrides.script.field | tts_prompt · voicemail_message · sms_body | yes | |
variants[].overrides.script.text | string | yes | |
variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
variants[].overrides.window.start | string | yes | |
variants[].overrides.window.end | string | yes | |
variants[].overrides.min_gap_seconds | integer | no | |
variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
Responses
201
The draft experiment.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (Experiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.campaign_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | contact_rate · promise_rate | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.variants | array of ExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiment.variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiment.variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiment.variants[].overrides.min_gap_seconds | integer | no | |
experiment.variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 — ExperimentUnprocessable
The body parsed but is not a legal experiment (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list and the campaign's own rules). Every refused field is in error.details, each with its path (variants[1].overrides.window, 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: optout_digit; allowed: script, window, min_gap_seconds, email_template_id",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: optout_digit; allowed: script, window, min_gap_seconds, email_template_id"
}
]
}
}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
http
GET /v1/campaigns/{id}/experiments/{experiment_id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
Responses
200
The experiment with its variants.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (Experiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.campaign_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | contact_rate · promise_rate | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.variants | array of ExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiment.variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiment.variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiment.variants[].overrides.min_gap_seconds | integer | no | |
experiment.variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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
http
PATCH /v1/campaigns/{id}/experiments/{experiment_id}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.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
primary_metric | contact_rate · promise_rate | no | |
variants | array of ExperimentVariantWrite | no | |
variants[].name | string | yes | |
variants[].weight | integer | yes | Share of the traffic, relative to the sum of the weights. |
variants[].is_control | boolean | no | Exactly one variant of the experiment is the control. |
variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
variants[].overrides.script.field | tts_prompt · voicemail_message · sms_body | yes | |
variants[].overrides.script.text | string | yes | |
variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
variants[].overrides.window.start | string | yes | |
variants[].overrides.window.end | string | yes | |
variants[].overrides.min_gap_seconds | integer | no | |
variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
Responses
200
The edited experiment.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (Experiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.campaign_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | contact_rate · promise_rate | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.variants | array of ExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiment.variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiment.variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiment.variants[].overrides.min_gap_seconds | integer | no | |
experiment.variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 — ExperimentUnprocessable
The body parsed but is not a legal experiment (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list and the campaign's own rules). Every refused field is in error.details, each with its path (variants[1].overrides.window, 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: optout_digit; allowed: script, window, min_gap_seconds, email_template_id",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: optout_digit; allowed: script, window, min_gap_seconds, email_template_id"
}
]
}
}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
http
DELETE /v1/campaigns/{id}/experiments/{experiment_id}Only a draft can be deleted; a running or ended experiment is 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
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"
}
}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 verdict of an A/B experiment
http
GET /v1/campaigns/{id}/experiments/{experiment_id}/resultsRecomputed from the ledger on every read (no counters): the original attempts stamped with the experiment, their corrections and the promises that point at them. For an SMS or email campaign the ledger is its message ledger: the original outbound messages and their status transitions, where a message the provider never took is not_placed, a contact is a delivered message and promises and abandoned are 0 (their rates null). Per variant: assigned debts, attempts, placed attempts (the not_placed ones are outside every denominator), contacts, promises, abandons and the rates. verdict.status is insufficient_data (some arm is under 100 placed attempts or 10 successes), no_significant_difference or winner (two-proportion z test, alpha 0.05, Bonferroni with more than two arms). When the observed split of debts deviates from the weights (chi-square, alpha 0.001) allocation_warning is true and no winner is emitted. A draft answers with zeros and insufficient_data.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
Responses
200
The results of the experiment.
| Field | Type | Required | Description |
|---|---|---|---|
results | object (ExperimentResults) | yes | |
results.experiment | object | yes | |
results.experiment.id | string (uuid) | no | |
results.experiment.campaign_id | string (uuid) | no | |
results.experiment.name | string | no | |
results.experiment.state | draft · running · ended | no | |
results.experiment.primary_metric | contact_rate · promise_rate | no | |
results.experiment.started_at | string,null (date-time) | no | |
results.experiment.ended_at | string,null (date-time) | no | |
results.variants | array of ExperimentVariantResults | yes | |
results.variants[].variant_id | string (uuid) | no | |
results.variants[].name | string | no | |
results.variants[].is_control | boolean | no | |
results.variants[].weight | integer | no | |
results.variants[].assigned_debts | integer | no | Distinct debts with an attempt in this variant (the unit of randomization). |
results.variants[].attempts | integer | no | |
results.variants[].not_placed | integer | no | Attempts that never left (outside every denominator). |
results.variants[].placed | integer | no | attempts minus not_placed; the denominator of the rates. |
results.variants[].contacts | integer | no | Placed attempts answered by a person (an answering machine is not a contact). For an SMS or email campaign, placed messages that were delivered. |
results.variants[].promises | integer | no | Placed attempts with at least one non-cancelled promise. Always 0 for an SMS or email campaign (a promise is attributed to a call attempt). |
results.variants[].abandoned | integer | no | Contacts that ended with no agent to take them. |
results.variants[].successes | integer | no | contacts or promises, per the experiment's primary_metric. |
results.variants[].contact_rate | number,null | no | contacts / placed; null when nothing was placed. |
results.variants[].promise_rate | number,null | no | promises / placed; null when nothing was placed and always null for an SMS or email campaign. |
results.variants[].abandon_rate | number,null | no | abandoned / contacts; null when there were no contacts and always null for an SMS or email campaign. |
results.unattributed_attempts | integer | yes | Attempts stamped with the experiment but with no variant, or one the experiment does not have. Non-zero means the stamp and the experiment disagree. |
results.verdict | object (ExperimentVerdict) | yes | |
results.verdict.status | insufficient_data · no_significant_difference · winner | yes | |
results.verdict.winner | null | object | yes | |
results.verdict.withheld_by | null | allocation_warning | yes | Set when a would-be winner was withheld because of the allocation warning. |
results.verdict.comparisons | array of object | yes | One entry per challenger; empty while the sample floor is not met. |
results.verdict.comparisons[].variant_id | string (uuid) | no | |
results.verdict.comparisons[].rate | number | no | |
results.verdict.comparisons[].difference | number | no | Challenger minus control. |
results.verdict.comparisons[].z | number | no | |
results.verdict.comparisons[].p_value | number | no | |
results.verdict.comparisons[].adjusted_p_value | number | no | |
results.verdict.comparisons[].ci_low | number | no | |
results.verdict.comparisons[].ci_high | number | no | |
results.verdict.comparisons[].significant | boolean | no | |
results.verdict.sample_floor | object | yes | |
results.verdict.sample_floor.min_placed | integer | no | |
results.verdict.sample_floor.min_successes | integer | no | |
results.verdict.sample_floor.met | boolean | no | |
results.allocation_warning | boolean | yes | The observed split of assigned debts deviates from the weights (chi-square, alpha 0.001). A winner is never emitted while it is true. |
results.allocation | object | yes | |
results.allocation.chi_square | number | no | |
results.allocation.degrees_of_freedom | integer | no | |
results.allocation.p_value | number | no | |
results.allocation.warning | boolean | 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"
}
}Start an A/B experiment
http
POST /v1/campaigns/{id}/experiments/{experiment_id}/startdraft → running. The variants are validated again against the campaign as it is now (422 with details if it no longer holds). Experiments run on voice, SMS and email campaigns whose messages or calls the platform sends itself. A preview or manual campaign (details[].path = "campaign", «experiments need an auto-dialed campaign; preview and manual are not») is 422 here, even though creating the draft is accepted. A campaign with another experiment running, or an experiment that is not a draft, is 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
Responses
200
The experiment, now running.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (Experiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.campaign_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | contact_rate · promise_rate | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.variants | array of ExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiment.variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiment.variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiment.variants[].overrides.min_gap_seconds | integer | no | |
experiment.variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 — ExperimentUnprocessable
The body parsed but is not a legal experiment (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list and the campaign's own rules). Every refused field is in error.details, each with its path (variants[1].overrides.window, 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: optout_digit; allowed: script, window, min_gap_seconds, email_template_id",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: optout_digit; allowed: script, window, min_gap_seconds, email_template_id"
}
]
}
}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
http
POST /v1/campaigns/{id}/experiments/{experiment_id}/stoprunning → ended (terminal). Anything else → 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the campaign. |
Responses
200
The experiment, now ended.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (Experiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.campaign_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | contact_rate · promise_rate | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.variants | array of ExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order Assignment.pick/3 walks the variants in (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (ExperimentVariantOverrides) | no | What a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored). |
experiment.variants[].overrides.script | object | no | Goes through the campaign's own write validators (an sms_body needs «STOP»). |
experiment.variants[].overrides.window | object | no | A SUBSET of the campaign's calling window; it never widens it. |
experiment.variants[].overrides.min_gap_seconds | integer | no | |
experiment.variants[].overrides.email_template_id | string (uuid) | no | An APPROVED template of the tenant. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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"
}
}Human-initiated dial (preview/manual campaigns)
http
POST /v1/campaigns/{id}/dialAn agent places ONE call (no auto-pacing) to a debt's primary contact, through the SAME gated Originator as campaigns — all compliance gates and the spend cap apply. A compliance block is a 200 with status: "blocked" (a valid outcome the agent must see, not a server error). Only preview/manual campaign types accept it (422 otherwise).
Caller-ID comes from the campaign's pool, resolved on every click with the same local-presence pick and anti-reuse cap the auto-pacer uses. A pool that is configured but yields no usable number right now (every DID quarantined or retired, the reuse cap took them all, or the registry could not be read) is 422 caller_id_unavailable: the call is not placed from an unvalidated number.
The agent's softphone is checked before the consumer is dialed (core#1395). When the bridge goes through the SIP edge (a browser seat), core asks whether the agent's softphone is registered, and places no call when it cannot bridge to it: 409 softphone_unregistered — not registered yet, within 30 s of the check-in (retry shortly; the seat is kept); 409 softphone_unreachable — still not registered after that grace: the seat is checked out and the agent must check in again; 503 registration_unverifiable — the registration could not be checked right now (retryable; the seat is kept).
Who may name whom (ADR #133, ADR #134). The agent_id is bound by the PRINCIPAL, the same rule on every verb that reads one: a dd_ machine key names any agent of its tenant; a ddw_ only its own; a ddu_ login session linked to an agent seat acts as THAT seat — agent_id may be omitted (filled with the seat) or repeat it, and naming another agent is 403 forbidden; a ddu_ session with NO linked seat may name another agent only with users.role = account_admin — a member is 403 role_forbidden — once the tenant's catalog key ddu_author_role_enforced is on (born off: until then the seatless session keeps the dd_ contract on this verb).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
debt_id | string (uuid) | yes | |
agent_id | string (uuid) | no | The dialing agent. Required for a dd_ key (400 when missing); a ddu_ session linked to a seat may omit it (its seat) and may not name another. |
json
{
"debt_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"
}Responses
200
Dial outcome — dialing, or blocked by a compliance gate.
| Field | Type | Required | Description |
|---|---|---|---|
status | dialing · blocked | yes | |
call_id | string (uuid) | no | Present when status is dialing. |
reason | tcpa_no_consent · quiet_hours · regf_7in7 · regf_post_contact · state_limit · dnc_listed · consent_revoked · cease_and_desist · number_reassigned · tenant_policy · artificial_voice_no_consent | no | The compliance engine's reason-code vocabulary. |
dialing
json
{
"status": "dialing",
"call_id": "6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c"
}blocked
json
{
"status": "blocked",
"reason": "quiet_hours"
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — Throttled
Dial rate brake (retryable later with backoff). No Retry-After header is set: the throttle does not know when capacity returns. Over the per-key quota this operation answers rate_limited as in ApiKeyRateLimited.
json
{
"error": {
"code": "throttled",
"message": "dial throttled (rate)"
}
}502 — DialError
The dialer could not place the call (switch rejected the originate).
json
{
"error": {
"code": "dial_error",
"message": "the dialer could not place the call"
}
}503
The agent's softphone registration could not be verified; no call was placed (retryable).
json
{
"error": {
"code": "registration_unverifiable",
"message": "the agent's softphone registration could not be verified; no call was placed"
}
}Dial a typed number (dialpad), resolved to one of the tenant's accounts
http
POST /v1/dial/manualThe agent TYPES an E.164 number. It is resolved against the phones of the tenant's own accounts (RLS-scoped). Only when it matches exactly ONE dialable account is the call placed, through the SAME pipeline as POST /v1/campaigns/{id}/dial — the account's own campaign, the agent seat, the softphone check and the gated Originator (Consent, DNC, the destination-local calling window, Reg F frequency, state matrix) — so the outcomes of that verb apply unchanged: 200 dialing, 200 blocked with the gate's reason, 409, 429, 503.
Fail-closed, no call is placed (422, machine-readable code): invalid_number (not E.164), no_account (no dialable account of the tenant holds the number — a number with no account is NOT dialable), ambiguous_account (more than one account holds it), no_campaign (the account has no campaign to dial it through), number_quarantined. 503 audit_unavailable when the request could not be recorded, and 503 resolution_unavailable when the account lookup failed.
Every request is audited append-only (who typed which number, and each block reason). Who may name whom is the rule of POST /v1/campaigns/{id}/dial (ADR #133, ADR #134): a ddw_ only its own agent_id (another is 403 forbidden), a dd_ any agent of its tenant, a seated ddu_ its own seat.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
number | string | yes | The typed destination, E.164 (+13125550100). |
agent_id | string (uuid) | no | The dialing agent. Required for a dd_ key (400 when missing); a ddw_ must name its own agent (missing or another is 403 forbidden); a seated ddu_ may omit it (its seat) and may not name another. |
json
{
"number": "+13125551001",
"agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"
}Responses
200
Dial outcome — dialing, or blocked by a compliance gate.
| Field | Type | Required | Description |
|---|---|---|---|
status | dialing · blocked | yes | |
call_id | string (uuid) | no | Present when status is dialing. |
reason | tcpa_no_consent · quiet_hours · regf_7in7 · regf_post_contact · state_limit · dnc_listed · consent_revoked · cease_and_desist · number_reassigned · tenant_policy · artificial_voice_no_consent | no | The compliance engine's reason-code vocabulary. |
dialing
json
{
"status": "dialing",
"call_id": "6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c"
}blocked
json
{
"status": "blocked",
"reason": "dnc"
}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
The typed number does not resolve to exactly one dialable account; no call was placed.
json
{
"error": {
"code": "no_account",
"message": "no account of this tenant holds that number; no call was placed"
}
}429 — Throttled
Dial rate brake (retryable later with backoff). No Retry-After header is set: the throttle does not know when capacity returns. Over the per-key quota this operation answers rate_limited as in ApiKeyRateLimited.
json
{
"error": {
"code": "throttled",
"message": "dial throttled (rate)"
}
}502 — DialError
The dialer could not place the call (switch rejected the originate).
json
{
"error": {
"code": "dial_error",
"message": "the dialer could not place the call"
}
}503
The dial was not placed and is retryable — the softphone registration, the audit write or the account lookup could not be completed.
json
{
"error": {
"code": "audit_unavailable",
"message": "the dial could not be recorded, so it was not placed; retry"
}
}Preview the next dialable lead (without dialing)
http
GET /v1/campaigns/{id}/next-leadParameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The next dialable lead, or null when the feed is empty.
| Field | Type | Required | Description |
|---|---|---|---|
lead | PreviewLead | null | yes |
json
{
"lead": {
"debt_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"debt_ref": "cms-debt-0001",
"account_number": "ACC-1001",
"debtor_name": "Ana María Pérez",
"debt_type": "other",
"debt_state": "open",
"contact_phone": "+13125551001",
"line_type": "mobile",
"consumer_state": "IL",
"consumer_timezone": "America/Chicago",
"last_attempt_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"
}
}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"
}
}Compliance preflight (dry-run) of a campaign's dialable leads
http
GET /v1/campaigns/{id}/compliance-preflightRuns the compliance engine in DRY-RUN over the leads the auto-pacer could offer for this campaign (one ranked contact per debt, dialable state, retry watermark elapsed, quarantine applied, agentless deliveries excluded) and returns ONE aggregate keyed by consumer state: which state rule each lead falls under, how many the gates would allow and how many they would block, by blocking rule and reason. Marks nothing and returns no individual lead. Same engine, same Request and same durable stores as dialing, so the counters are what launching the campaign NOW would produce.
Honest about the configurable state gate: with state_matrix_unknown_state_fails_strict OFF for the tenant (the catalog default) a lead with no usable us_state keeps only the federal 7-in-7 — unknown_state_policy and tenant_gates.state_matrix say federal_fallback, never "protected". ON, they say strict / active and the bucket carries the gate's own rule id.
Cost is bounded: at most 10000 leads are evaluated per call; totals.leads is the exact population count and truncated: true says the evaluation covered only the first totals.evaluated of it.
List health (list_health, core#1181). states counts each lead under its FIRST block only, so a list with debts lacking consent AND an expired DNC snapshot shows one of the two. list_health.signals reads the same evaluated leads once per signal, none stopping the others: consent, state (the us_state resolves to a canonical code), dnc (the lists, their snapshots and the per-state registry) and rnd (Reassigned Numbers). A signal that cannot decide — a DNC list with no fresh snapshot, no RND provider declared, an RND without data or in error, a gate that crashed, a state that does not resolve — is no_verificable and COUNTS AS A BLOCK (blocked includes no_verificable), never as healthy. The cell-level premises ride alongside (age of each DNC snapshot, RND provider), so a signal whose premise is unverifiable says no_verificable even with zero leads. Additive: states and totals are unchanged.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
Preflight aggregate
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) | yes | |
unknown_state_policy | strict · federal_fallback | yes | Posture of the state matrix for a consumer_state outside its canonical shape, per the tenant flag. |
tenant_gates | object | yes | Posture of each gate for this tenant. Only state_matrix has an off switch today. |
tenant_gates.dnc | active | no | |
tenant_gates.consent | active | no | |
tenant_gates.quiet_hours | active | no | |
tenant_gates.reg_f | active | no | |
tenant_gates.state_matrix | active · federal_fallback | no | |
totals | object | yes | |
totals.leads | integer | no | Exact size of the dialable population. |
totals.evaluated | integer | no | Leads the engine was run over (≤ leads). |
totals.eligible | integer | no | |
totals.suppressed | integer | no | |
totals.unknown_state | integer | no | Evaluated leads with an empty us_state. |
truncated | boolean | yes | true when evaluated < leads (evaluation bound hit). |
states | object | yes | Keyed by the RAW us_state the engine sees ("unknown" for the empty string). |
list_health | object | yes | Per-signal health of the evaluated leads, without short-circuit (core#1181). |
list_health.signals | object | yes | |
list_health.signals.consent | object (PreflightSignal) | yes | One signal of the preflight's list health, over the evaluated leads. blocked counts every lead this signal refuses, INCLUDING the no_verificable ones (a signal that cannot decide blocks). status is no_verificable when any lead was unverifiable or the cell-level premise is (DNC list without a fresh snapshot, no RND provider), else blocking when blocked > 0, else ok. |
list_health.signals.consent.status | ok · blocking · no_verificable | yes | |
list_health.signals.consent.blocked | integer | yes | |
list_health.signals.consent.no_verificable | integer | yes | |
list_health.signals.consent.rules | object | yes | Blocked leads per rule id (for state, the state-matrix label the lead falls under). |
list_health.signals.state | object (PreflightSignal) | yes | One signal of the preflight's list health, over the evaluated leads. blocked counts every lead this signal refuses, INCLUDING the no_verificable ones (a signal that cannot decide blocks). status is no_verificable when any lead was unverifiable or the cell-level premise is (DNC list without a fresh snapshot, no RND provider), else blocking when blocked > 0, else ok. |
list_health.signals.state.status | ok · blocking · no_verificable | yes | |
list_health.signals.state.blocked | integer | yes | |
list_health.signals.state.no_verificable | integer | yes | |
list_health.signals.state.rules | object | yes | Blocked leads per rule id (for state, the state-matrix label the lead falls under). |
list_health.signals.dnc | object (PreflightSignal) | yes | One signal of the preflight's list health, over the evaluated leads. blocked counts every lead this signal refuses, INCLUDING the no_verificable ones (a signal that cannot decide blocks). status is no_verificable when any lead was unverifiable or the cell-level premise is (DNC list without a fresh snapshot, no RND provider), else blocking when blocked > 0, else ok. |
list_health.signals.dnc.status | ok · blocking · no_verificable | yes | |
list_health.signals.dnc.blocked | integer | yes | |
list_health.signals.dnc.no_verificable | integer | yes | |
list_health.signals.dnc.rules | object | yes | Blocked leads per rule id (for state, the state-matrix label the lead falls under). |
list_health.signals.rnd | object (PreflightSignal) | yes | One signal of the preflight's list health, over the evaluated leads. blocked counts every lead this signal refuses, INCLUDING the no_verificable ones (a signal that cannot decide blocks). status is no_verificable when any lead was unverifiable or the cell-level premise is (DNC list without a fresh snapshot, no RND provider), else blocking when blocked > 0, else ok. |
list_health.signals.rnd.status | ok · blocking · no_verificable | yes | |
list_health.signals.rnd.blocked | integer | yes | |
list_health.signals.rnd.no_verificable | integer | yes | |
list_health.signals.rnd.rules | object | yes | Blocked leads per rule id (for state, the state-matrix label the lead falls under). |
list_health.dnc_snapshots | object | yes | The three global DNC lists, each against the 31-day ceiling. |
list_health.dnc_snapshots.federal | object (PreflightDncSnapshot) | yes | |
list_health.dnc_snapshots.federal.status | provisioned · stale · absent | yes | |
list_health.dnc_snapshots.federal.age_days | integer,null | yes | Days since the snapshot was imported; null when absent. |
list_health.dnc_snapshots.federal.max_age_days | integer | yes | Freshness ceiling for this list. |
list_health.dnc_snapshots.federal.source | string | yes | Provenance string of the snapshot (none when absent). |
list_health.dnc_snapshots.federal.record_count | integer,null | yes | |
list_health.dnc_snapshots.state | object (PreflightDncSnapshot) | yes | |
list_health.dnc_snapshots.state.status | provisioned · stale · absent | yes | |
list_health.dnc_snapshots.state.age_days | integer,null | yes | Days since the snapshot was imported; null when absent. |
list_health.dnc_snapshots.state.max_age_days | integer | yes | Freshness ceiling for this list. |
list_health.dnc_snapshots.state.source | string | yes | Provenance string of the snapshot (none when absent). |
list_health.dnc_snapshots.state.record_count | integer,null | yes | |
list_health.dnc_snapshots.internal_global | object (PreflightDncSnapshot) | yes | |
list_health.dnc_snapshots.internal_global.status | provisioned · stale · absent | yes | |
list_health.dnc_snapshots.internal_global.age_days | integer,null | yes | Days since the snapshot was imported; null when absent. |
list_health.dnc_snapshots.internal_global.max_age_days | integer | yes | Freshness ceiling for this list. |
list_health.dnc_snapshots.internal_global.source | string | yes | Provenance string of the snapshot (none when absent). |
list_health.dnc_snapshots.internal_global.record_count | integer,null | yes | |
list_health.dnc_state_registries | object | yes | Every declared per-state DNC registry, keyed by state code, each against its own ceiling. |
list_health.rnd_provider | object | yes | |
list_health.rnd_provider.status | declared · stub · no_verificable | yes | stub is the development provider that answers clean without asking a registry; no_verificable means none is declared (the gate refuses every dial). |
list_health.rnd_provider.provider | string,null | yes | Name of the declared provider; null when none. |
json
{
"campaign_id": "0190b2a4-7c3e-7f21-9d2a-3f1c2b9e8a10",
"unknown_state_policy": "federal_fallback",
"tenant_gates": {
"dnc": "active",
"consent": "active",
"quiet_hours": "active",
"reg_f": "active",
"state_matrix": "federal_fallback"
},
"totals": {
"leads": 4,
"evaluated": 4,
"eligible": 3,
"suppressed": 1,
"unknown_state": 1
},
"truncated": false,
"states": {
"MA": {
"leads": 1,
"eligible": 0,
"suppressed": 1,
"rules": {
"state.ma.2in7.v1": 1
},
"blocks": {
"state.ma.2in7.v1": {
"state_limit": 1
}
}
},
"WA": {
"leads": 1,
"eligible": 1,
"suppressed": 0,
"rules": {
"state.wa.3in7.v1": 1
},
"blocks": {}
},
"NY": {
"leads": 1,
"eligible": 1,
"suppressed": 0,
"rules": {
"city.nyc.2in7.v1": 1
},
"blocks": {}
},
"unknown": {
"leads": 1,
"eligible": 1,
"suppressed": 0,
"rules": {
"federal_fallback": 1
},
"blocks": {}
}
},
"list_health": {
"signals": {
"consent": {
"status": "blocking",
"blocked": 2,
"no_verificable": 0,
"rules": {
"tcpa.consent.v1": 2
}
},
"state": {
"status": "no_verificable",
"blocked": 1,
"no_verificable": 1,
"rules": {
"federal_fallback": 1
}
},
"dnc": {
"status": "blocking",
"blocked": 1,
"no_verificable": 0,
"rules": {
"dnc.lists.v1": 1
}
},
"rnd": {
"status": "ok",
"blocked": 0,
"no_verificable": 0,
"rules": {}
}
},
"dnc_snapshots": {
"federal": {
"status": "provisioned",
"age_days": 3,
"max_age_days": 31,
"source": "ftc:2026-09-23",
"record_count": 240000000
},
"state": {
"status": "provisioned",
"age_days": 3,
"max_age_days": 31,
"source": "states:2026-09-23",
"record_count": 1200000
},
"internal_global": {
"status": "provisioned",
"age_days": 0,
"max_age_days": 31,
"source": "platform",
"record_count": 1500
}
},
"dnc_state_registries": {
"FL": {
"status": "absent",
"age_days": null,
"max_age_days": 31,
"source": "none",
"record_count": null
}
},
"rnd_provider": {
"status": "declared",
"provider": "Example.RND.Provider"
}
}
}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"
}
}Live runner + pacing counters
http
GET /v1/campaigns/{id}/statsrunning: false, stats: null when no runner is up. Voice runners report {dialed, blocked, finished, reaped, throttled, pool_unavailable, pool_unavailable_reason, no_agent_seat, no_rate, tenant_throttled, tenant_throttle_reason, tenant_throttle_by_reason, tenant_throttle_last_at, prompt_unavailable, prompt_unavailable_reason, overdial_disarmed, thirty_day_abandonment, overdial_margin, in_flight, pacing}; SMS runners report {sent, duplicate, blocked, failed, pending_cooldown, send_unavailable_reason}.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
Campaign state + live stats.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) | yes | |
state | draft · running · paused · completed · archived · suspended_balance · exhausted | yes | |
running | boolean | yes | |
stats | VoiceRunnerStats | SmsRunnerStats | null | yes |
json
{
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"state": "running",
"running": true,
"stats": {
"dialed": 42,
"blocked": 5,
"finished": 30,
"reaped": 0,
"pool_unavailable": 0,
"pool_unavailable_reason": null,
"no_agent_seat": 0,
"no_rate": 2,
"tenant_throttled": 120,
"tenant_throttle_reason": "exposure_limit",
"tenant_throttle_by_reason": {
"exposure_limit": 120
},
"tenant_throttle_last_at": "2026-09-29T16:58:00Z",
"in_flight": 7,
"pacing": {
"ratio": 1.6,
"abandoned": 1,
"connected": 22,
"abandonment_rate": 0.0435,
"tokens": 3
}
}
}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"
}
}Survey / IVR keypress distribution
http
GET /v1/campaigns/{id}/survey-resultsThe read side of the agentless survey responses: how many consumers pressed each DTMF key on this campaign over [from, to] (both inclusive; default trailing 30 UTC days), optionally narrowed to one question_id (q1 is the default question the call FSM records). distribution is sorted by digit; total is the sum. Read-only and tenant-scoped: a campaign of another tenant is 404, never an empty 200. ?format=csv (or Accept: text/csv) returns the same numbers as a CSV artifact with its own X-Artifact-SHA256 — same path as /v1/stats/ai-usage and /v1/reports/violations-prevented.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
question_id | query | string | no | Narrow to one question (non-empty string). Omitted = every question. |
format | query | csv | no | csv for the downloadable artifact (equivalent: Accept: text/csv). |
Responses
200
Digit distribution (JSON) or the CSV artifact.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) | yes | |
from | string (date-time) | yes | |
to | string (date-time) | yes | |
question_id | string | null | yes | The filter that was applied; null = every question. |
total | integer | yes | |
distribution | array of object | yes | One row per digit pressed at least once, sorted by digit. |
distribution[].digit | string | yes | |
distribution[].count | integer | yes |
json
{
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"from": "2026-06-01T00:00:00Z",
"to": "2026-06-30T23:59:59Z",
"question_id": null,
"total": 4,
"distribution": [
{
"digit": "1",
"count": 2
},
{
"digit": "2",
"count": 1
},
{
"digit": "9",
"count": 1
}
]
}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"
}
}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"
}
}Durable abandonment rate per 30-day period
http
GET /v1/campaigns/{id}/abandonmentThe records of 47 CFR §64.1200(a)(7)(iii) and 16 CFR §310.4(b)(4)(iv): the campaign's abandonment rate for each successive 30-day period, anchored at its first placed dial (anchored_at), folded at read time from the append-only CDR (call_attempts). No counter on the call path feeds it. The live rate of /v1/campaigns/{id}/stats is the pacing controller's 120-second window; this one is the record.
Measured: the outbound dials the platform placed (not the not_placed liquidation, an inbound leg, an external-transfer carrier leg or a cms_import row). Denominator answered_live: answered, without an AMD machine verdict. Numerator abandoned, by reason (predicate.counts): no_agent (disposition abandoned: answered in an agent campaign and no representative connected, with no seat reserved for the attempt), ai_stream_lost (the voice-AI fork was lost and nothing was transferred) and agent_bridge_failed (the same abandoned answer, but the attempt carries an agent_id: a seat was reserved and the bridge to it failed). agent_bridge_failed is the part of what no_agent counted until core#1383 that had a seat: the two causes ask for different fixes, and the split moves neither abandoned nor rate. An artificial-voice delivery (delivered, ai_conversation) is not an abandon (§64.1200(a)(7)(ii), §310.4(b)(1)(v)(C)); a voice-AI handoff that found no seat is reported apart as handoff_failed and is not in the rate (predicate.reported_apart). Also reported apart, since core#1426: late_bridge, a live answer not counted as abandoned whose consumer leg was bridged MORE than 2 s after answered_at (§310.4(b)(1)(iv): "within two (2) seconds of the person's completed greeting"; answered_at stands in for the end of the greeting, so the delay is measured long, never short). A transfer the voice-AI or the IVR ordered is not counted, nor is a call written before the bridge instant was recorded. exceeds_ceiling is "more than three percent", decided in integers; rate is null with no live answer. Rows written before core 0.73.0 keep the disposition they were born with, so an agentless delivery of that era reads abandoned and counts. A period with no dial is not listed.
Read-only and tenant-scoped: a campaign of another tenant is 404, never an empty 200. ?format=csv (or Accept: text/csv) returns the same numbers as a CSV artifact with its own X-Artifact-SHA256.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
format | query | csv | no | csv for the downloadable artifact (equivalent: Accept: text/csv). |
Responses
200
The report (JSON) or the CSV artifact.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) | yes | |
measured_at | string (date-time) | yes | |
period_days | 30 | yes | |
ceiling_percent | 3 | yes | |
anchored_at | string (date-time) | null | yes | The campaign's first placed dial; null = no dial yet (and no period). |
predicate | object | yes | |
predicate.counts | array of no_agent · ai_stream_lost · agent_bridge_failed · handoff_failed · late_bridge | yes | The reasons the rate counts as abandoned. |
predicate.reported_apart | array of no_agent · ai_stream_lost · agent_bridge_failed · handoff_failed · late_bridge | yes | Reasons reported per period and NOT counted in the rate. |
periods | array of AbandonmentPeriod | yes | One entry per 30-day period with at least one placed dial, oldest first. |
periods[].index | integer | yes | [anchored_at + 30·index days, + 30 days). |
periods[].starts_at | string (date-time) | yes | |
periods[].ends_at | string (date-time) | yes | Exclusive. |
periods[].complete | boolean | yes | ends_at is not after measured_at. |
periods[].placed | integer | yes | |
periods[].answered | integer | yes | |
periods[].answered_machine | integer | yes | Answered with an AMD machine verdict. |
periods[].answered_live | integer | yes | The denominator: answered - answered_machine. |
periods[].abandoned | integer | yes | The numerator; the sum of abandoned_by_reason. |
periods[].abandoned_by_reason | object | yes | One key per reason in predicate.counts. Readers must not assume a closed set of keys: agent_bridge_failed was added in core#1383. |
periods[].handoff_failed | integer | yes | Voice-AI handoffs with no seat; not in the rate. |
periods[].late_bridge | integer | yes | Live answers not counted as abandoned whose consumer leg was bridged more than 2 s after answered_at (core#1426); not in the rate. |
periods[].rate | number | null | yes | abandoned / answered_live, 4 decimals; null with no live answer. |
periods[].exceeds_ceiling | boolean | yes | abandoned * 100 > 3 * answered_live. |
json
{
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"measured_at": "2026-09-29T22:00:00.000000Z",
"period_days": 30,
"ceiling_percent": 3,
"anchored_at": "2026-08-01T15:00:00.000000Z",
"predicate": {
"counts": [
"no_agent",
"ai_stream_lost",
"agent_bridge_failed"
],
"reported_apart": [
"handoff_failed",
"late_bridge"
]
},
"periods": [
{
"index": 0,
"starts_at": "2026-08-01T15:00:00.000000Z",
"ends_at": "2026-08-31T15:00:00.000000Z",
"complete": true,
"placed": 1200,
"answered": 410,
"answered_machine": 60,
"answered_live": 350,
"abandoned": 7,
"abandoned_by_reason": {
"no_agent": 5,
"ai_stream_lost": 0,
"agent_bridge_failed": 2
},
"handoff_failed": 0,
"late_bridge": 4,
"rate": 0.02,
"exceeds_ceiling": false
}
]
}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"
}
}Why an e-mail campaign did not send to a lead
http
GET /v1/campaigns/{id}/email-skipsThe record of every lead the e-mail runner DISCARDED without sending and without a compliance block, and of every refusal that HALTED its tick: a running email_blast campaign with nothing in /v1/stats/blocked and nothing in /v1/email/messages is explained here. One append-only row per (campaign, debt, reason, UTC day) — the runner offers the same debt again every lap and that is one row per day. The numbers are derived from the rows at read time; nothing is counted on the send path.
reasons lists EVERY reason of the closed vocabulary (zeros included): debt_not_found, debt_not_dialable (the debt left the dialable states), no_sendable_address (no contact has an address with valid provenance that is not suppressed), email_address_invalid and email_debt_unresolved (the sender refused that lead only), and the throttled_* reasons (core#2105): the sender answered {:throttled, motivo}, a refusal that is the same for every lead, so the tick halts and the debt is offered again — throttled_plan_inactive, throttled_rate_limit, throttled_provider_open, throttled_spend_cap and throttled_no_rate (no e-mail rate is configured for the sending region, so nothing is sent: fail-closed) and throttled_synthetic_tenant (core#2251: the tenant is a synthetic demo tenant, which originates no outbound traffic). For a throttled_* reason debt_id is the lead the refusal met, and the row is written when the reason changes or the UTC day does, not once per tick. leads is the number of distinct debts discarded for the reason; days the UTC days with a row; first_at / last_at are null with no row. total_leads counts a debt discarded for two different reasons once. samples are the latest rows, newest first, optionally narrowed with reason. No address is ever served.
Read-only and tenant-scoped (a dd_ key or a dashboard login): a campaign of another tenant is 404, never an empty 200.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
reason | query | debt_not_found · debt_not_dialable · no_sendable_address · email_address_invalid · email_debt_unresolved · throttled_plan_inactive · throttled_rate_limit · throttled_provider_open · throttled_spend_cap · throttled_no_rate · throttled_synthetic_tenant | no | Narrow samples to one reason. Omitted = every reason. reasons is never narrowed. |
limit | query | integer | no | Size of samples (default 20, max 100). |
Responses
200
Per-reason numbers and the latest rows.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string (uuid) | yes | |
total_leads | integer | yes | Distinct debts discarded for ANY reason (a debt with two reasons counts once). |
reasons | array of EmailLeadSkipReasonCount | yes | Every reason of the closed vocabulary, zeros included, in vocabulary order. |
reasons[].reason | debt_not_found · debt_not_dialable · no_sendable_address · email_address_invalid · email_debt_unresolved · throttled_plan_inactive · throttled_rate_limit · throttled_provider_open · throttled_spend_cap · throttled_no_rate · throttled_synthetic_tenant | yes | |
reasons[].leads | integer | yes | Distinct debts discarded for this reason. |
reasons[].days | integer | yes | UTC days with at least one row for this reason. |
reasons[].first_at | string,null (date-time) | yes | |
reasons[].last_at | string,null (date-time) | yes | |
samples | array of EmailLeadSkipSample | yes | |
samples[].debt_id | string (uuid) | yes | |
samples[].reason | debt_not_found · debt_not_dialable · no_sendable_address · email_address_invalid · email_debt_unresolved · throttled_plan_inactive · throttled_rate_limit · throttled_provider_open · throttled_spend_cap · throttled_no_rate · throttled_synthetic_tenant | yes | |
samples[].occurred_at | string (date-time) | yes |
json
{
"campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
"total_leads": 3,
"reasons": [
{
"reason": "debt_not_found",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "debt_not_dialable",
"leads": 1,
"days": 1,
"first_at": "2026-10-10T15:02:00.000000Z",
"last_at": "2026-10-10T15:02:00.000000Z"
},
{
"reason": "no_sendable_address",
"leads": 2,
"days": 2,
"first_at": "2026-10-09T15:00:00.000000Z",
"last_at": "2026-10-10T15:00:00.000000Z"
},
{
"reason": "email_address_invalid",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "email_debt_unresolved",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_plan_inactive",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_rate_limit",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_provider_open",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_spend_cap",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_no_rate",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
},
{
"reason": "throttled_synthetic_tenant",
"leads": 0,
"days": 0,
"first_at": null,
"last_at": null
}
],
"samples": [
{
"debt_id": "8a1d5c0e-2b7f-4e49-9c3a-6f0b1d2e3a44",
"reason": "debt_not_dialable",
"occurred_at": "2026-10-10T15:02:00.000000Z"
},
{
"debt_id": "5e9f7a10-3c4d-4b8e-a1f2-7d6c5b4a3921",
"reason": "no_sendable_address",
"occurred_at": "2026-10-10T15:00:00.000000Z"
}
]
}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"
}
}Rewind dry-run (filters in query)
http
GET /v1/campaigns/{id}/rewind/previewDashboard-friendly GET twin of the POST preview. excluded_by_compliance counts matching debts the engine would block RIGHT NOW (informational; the gates re-enforce at every originate anyway).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
dispositions | query | string | no | Comma-separated list matched against each debt's LATEST disposition. |
max_attempts | query | integer | no | |
last_attempt_older_than_days | query | integer | no | |
amd_verdicts | query | string | no | Comma-separated AMD verdicts matched against each debt's latest placed attempt. |
segment_id | query | string (uuid) | no | A saved segment (/v1/rewind-segments) to use INSTEAD of the loose filters; combining it with any of them is 400. Resolved server-side and re-evaluated now. |
Responses
200
Dry-run result.
| Field | Type | Required | Description |
|---|---|---|---|
matching | integer | yes | |
excluded_by_compliance | integer | yes | Matching debts the engine would block RIGHT NOW (informational). |
debt_ids | array of string (uuid) | yes |
json
{
"matching": 12,
"excluded_by_compliance": 3,
"debt_ids": [
"5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d"
]
}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"
}
}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 — 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"
}
}Rewind dry-run (filters in body)
http
POST /v1/campaigns/{id}/rewind/previewSame dry-run as the GET, filters in the JSON body (B1 handoff shape).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
json
{
"max_attempts": 1
}Responses
200
Dry-run result.
| Field | Type | Required | Description |
|---|---|---|---|
matching | integer | yes | |
excluded_by_compliance | integer | yes | Matching debts the engine would block RIGHT NOW (informational). |
debt_ids | array of string (uuid) | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}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 — 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"
}
}Re-queue matching exhausted debts
http
POST /v1/campaigns/{id}/rewindSets THIS campaign's requeued_at watermark on each matching debt (the campaign's lead feed treats them as never-attempted; no other campaign's watermark moves) and resurrects closed → open — except a closed debt that is also in another campaign's population: its state belongs to every campaign that holds it, so it stays closed and is counted in kept_closed. paid/settled/disputed are NEVER rewound and the CDR is untouched (append-only; Reg F counters keep counting). keep_scheduled_callbacks: false cancels the campaign's pending callbacks for the rewound debts. amd_verdicts narrows by the latest placed attempt's AMD verdict; an unknown verdict answers 422. segment_id names a saved segment (/v1/rewind-segments) in place of the loose filters.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
json
{
"max_attempts": 1,
"keep_scheduled_callbacks": true
}Responses
200
Rewind applied.
| Field | Type | Required | Description |
|---|---|---|---|
requeued | integer | yes | |
callbacks_cancelled | integer | yes | |
kept_closed | integer | yes | Matching closed debts left closed because another campaign also has them in its population. Their watermark in this campaign moved anyway. |
json
{
"requeued": 9,
"callbacks_cancelled": 0,
"kept_closed": 0
}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"
}
}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 — 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"
}
}