Skip to content

Campaigns ​

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

Español

Campaign CRUD, lifecycle, manual dial, rewind.

Endpoints ​

MethodPathSummary
GET/v1/campaignsList campaigns
POST/v1/campaignsCreate a campaign (state draft)
GET/v1/campaigns/{id}Fetch a campaign
PATCH/v1/campaigns/{id}Update campaign config (never state)
POST/v1/campaigns/{id}/startStart a campaign
POST/v1/campaigns/{id}/resumeResume a paused campaign (alias of start)
POST/v1/campaigns/{id}/pausePause a running campaign
POST/v1/campaigns/{id}/stopStop a campaign (terminal completed)
POST/v1/campaigns/{id}/archiveArchive a campaign
GET/v1/campaigns/{id}/experimentsA/B experiments of a campaign
POST/v1/campaigns/{id}/experimentsCreate 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}/resultsResults and verdict of an A/B experiment
POST/v1/campaigns/{id}/experiments/{experiment_id}/startStart an A/B experiment
POST/v1/campaigns/{id}/experiments/{experiment_id}/stopEnd an A/B experiment
POST/v1/campaigns/{id}/dialHuman-initiated dial (preview/manual campaigns)
POST/v1/dial/manualDial a typed number (dialpad), resolved to one of the tenant's accounts
GET/v1/campaigns/{id}/next-leadPreview the next dialable lead (without dialing)
GET/v1/campaigns/{id}/compliance-preflightCompliance preflight (dry-run) of a campaign's dialable leads
GET/v1/campaigns/{id}/statsLive runner + pacing counters
GET/v1/campaigns/{id}/survey-resultsSurvey / IVR keypress distribution
GET/v1/campaigns/{id}/abandonmentDurable abandonment rate per 30-day period
GET/v1/campaigns/{id}/email-skipsWhy an e-mail campaign did not send to a lead
GET/v1/campaigns/{id}/rewind/previewRewind dry-run (filters in query)
POST/v1/campaigns/{id}/rewind/previewRewind dry-run (filters in body)
POST/v1/campaigns/{id}/rewindRe-queue matching exhausted debts

List campaigns ​

http
GET /v1/campaigns

Responses

200 ​

All campaigns of the tenant.

FieldTypeRequiredDescription
campaignsarray of Campaignyes
campaigns[].idstring (uuid)no
campaigns[].tenant_idstring (uuid)no
campaigns[].namestringno
campaigns[].statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaigns[].campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaigns[].channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaigns[].sms_bodystring | nullno
campaigns[].email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaigns[].email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaigns[].abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaigns[].min_dial_ratiostring | nullno
campaigns[].max_dial_ratiostring | nullno
campaigns[].calling_window_startstring | nullnoHH:MM:SS.
campaigns[].calling_window_endstring | nullno
campaigns[].default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaigns[].calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaigns[].starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaigns[].ai_voicebooleanno
campaigns[].amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaigns[].tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaigns[].voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaigns[].voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaigns[].ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaigns[].deferred_bridgebooleanno
campaigns[].consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaigns[].carrierstring | nullno
campaigns[].caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaigns[].caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaigns[].created_atstring (date-time)no
campaigns[].updated_atstring (date-time)no
truncatedbooleanyestrue 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/campaigns

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

FieldTypeRequiredDescription
namestringno
campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
sms_bodystringnoREQUIRED for sms_blast; must contain the STOP opt-out notice; supports merge vars.
email_template_idstring (uuid)noREQUIRED 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_variablesobjectnoStatic values (string or number) for the variables the template declares; validated against its declaration at write time (400 invalid).
abandonment_thresholdnumberno
min_dial_rationumberno
max_dial_rationumberno
calling_window_startstringnoHH:MM:SS.
calling_window_endstringno
default_timezonestringno
starts_atstring | nullnoThe 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_atstring | nullnoThe 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_localbooleanno
calling_window_weeklyCampaignWeeklyWindow | nullnoOne 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_callsbooleanno
ai_voicebooleanno
amd_enabledboolean | nullnoPer-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_digitstringnoExactly 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_promptstringnoScript 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_numberstringnoThe 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_messagestringnoThe 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_menuobjectnoDigit → 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_bridgebooleannoAgent 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_configobjectnoConsult-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.
carrierstringno
caller_idsarray of stringno
caller_id_poolarray 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).

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (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

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

Responses

200 ​

The campaign.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

429 — ApiKeyRateLimited ​

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

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

Update 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

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
sms_bodystringnoREQUIRED for sms_blast; must contain the STOP opt-out notice; supports merge vars.
email_template_idstring (uuid)noREQUIRED 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_variablesobjectnoStatic values (string or number) for the variables the template declares; validated against its declaration at write time (400 invalid).
abandonment_thresholdnumberno
min_dial_rationumberno
max_dial_rationumberno
calling_window_startstringnoHH:MM:SS.
calling_window_endstringno
default_timezonestringno
starts_atstring | nullnoThe 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_atstring | nullnoThe 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_localbooleanno
calling_window_weeklyCampaignWeeklyWindow | nullnoOne 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_callsbooleanno
ai_voicebooleanno
amd_enabledboolean | nullnoPer-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_digitstringnoExactly 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_promptstringnoScript 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_numberstringnoThe 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_messagestringnoThe 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_menuobjectnoDigit → 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_bridgebooleannoAgent 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_configobjectnoConsult-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.
carrierstringno
caller_idsarray of stringno
caller_id_poolarray of string (uuid)no
json
{
  "record_calls": false
}

Responses

200 ​

Updated campaign.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

403 — 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}/start

Persists 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

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

Request body (JSON)

FieldTypeRequiredDescription
tick_msintegerno
batch_sizeintegerno
msgs_per_tickintegernoSMS campaigns — default 1 (≈1 MPS, the long-code-safe 10DLC throughput).
gatewaystringnoSofia profile identifier.
agent_domainstringno
agent_sip_proxystringno"" opts the campaign out of the deployment-wide proxy.
call_ttl_msintegerno
blocked_cooldown_msintegerno
json
{
  "tick_ms": 1000,
  "batch_size": 2,
  "gateway": "default"
}

Responses

200 ​

The campaign, now running.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

403 — 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}/resume

Resume IS start (paused → running) — one semantics, two spellings (docs parity).

Parameters

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

Request body (JSON)

FieldTypeRequiredDescription
tick_msintegerno
batch_sizeintegerno
msgs_per_tickintegernoSMS campaigns — default 1 (≈1 MPS, the long-code-safe 10DLC throughput).
gatewaystringnoSofia profile identifier.
agent_domainstringno
agent_sip_proxystringno"" opts the campaign out of the deployment-wide proxy.
call_ttl_msintegerno
blocked_cooldown_msintegerno

Responses

200 ​

The campaign, now running.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

403 — 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}/pause

Persists paused and stops the runner; in-flight calls finish via the CDR pipeline.

Parameters

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

Responses

200 ​

The campaign, now paused.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

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}/stop

Parameters

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

Responses

200 ​

The campaign, now completed.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

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}/archive

draft|completed → archived (terminal). Anything else → 409.

Parameters

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

Responses

200 ​

The campaign, now archived.

FieldTypeRequiredDescription
campaignobject (Campaign)yes
campaign.idstring (uuid)no
campaign.tenant_idstring (uuid)no
campaign.namestringno
campaign.statedraft · running · paused · completed · archived · suspended_balance · exhaustedno
campaign.campaign_typepredictive · progressive · preview · manual · tts_blast · tts_ivr · ivr_cascade · survey · ai_voice · sms_blast · email_blastno
campaign.channelvoice · sms · emailnoDerived from the type profile — which runner the lifecycle dispatches to.
campaign.sms_bodystring | nullno
campaign.email_template_idstring (uuid) | nullnoThe approved email template version an email_blast campaign sends.
campaign.email_template_variablesobjectnoStatic values of the template's declared variables (email_blast).
campaign.abandonment_thresholdstring | nullnoDecimal rendered as a JSON string.
campaign.min_dial_ratiostring | nullno
campaign.max_dial_ratiostring | nullno
campaign.calling_window_startstring | nullnoHH:MM:SS.
campaign.calling_window_endstring | nullno
campaign.default_timezonestring | nullnoIANA timezone. The zone the OPERATIONAL calling window is read in while calling_window_destination_local is false.
campaign.calling_window_destination_localbooleannoRead 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_weeklyCampaignWeeklyWindow | nullnoThe per-day window as stored, normalised to HH:MM:SS. null = no weekly window. See CampaignWrite.calling_window_weekly.
campaign.starts_atstring | nullnoWall-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_atstring | nullnoWall-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_callsbooleanno
campaign.ai_voicebooleanno
campaign.amd_enabledboolean | nullnoThe 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_digitstring | nullnoThe single DTMF key that opts the consumer out (0-9, *, #); null = no opt-out key.
campaign.tts_promptstring | nullnoThe operator's script for the tts_* treatments; null = no prompt.
campaign.voicemail_optout_numberstring | nullnoThe NANP toll-free opt-out number the composed voicemail gives, as stored; null = no voicemail. See CampaignWrite.voicemail_optout_number.
campaign.voicemail_messagestring | nullnoThe voicemail text as written, with its {optout_number} marker; null = no voicemail. See CampaignWrite.voicemail_message.
campaign.ivr_menuobjectnoThe IVR menu as written ({} = no menu). See CampaignWrite.ivr_menu.
campaign.deferred_bridgebooleanno
campaign.consult_configobjectnoThe consult-line config as written ({} = disabled). See CampaignWrite.consult_config.
campaign.carrierstring | nullno
campaign.caller_idsarray of stringnoPrior round-robin pool of E.164 strings (used when caller_id_pool is empty).
campaign.caller_id_poolarray of string (uuid)noDID-registry ids; re-resolved every tick to the ACTIVE DIDs — an all-parked pool dials NOTHING (fail-closed).
campaign.created_atstring (date-time)no
campaign.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

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}/experiments

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

Parameters

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

Responses

200 ​

The campaign's experiments.

FieldTypeRequiredDescription
experimentsarray of Experimentyes
experiments[].idstring (uuid)no
experiments[].campaign_idstring (uuid)no
experiments[].namestringno
experiments[].statedraft · running · endedno
experiments[].primary_metriccontact_rate · promise_rateno
experiments[].started_atstring,null (date-time)no
experiments[].ended_atstring,null (date-time)no
experiments[].variantsarray of ExperimentVariantno
experiments[].variants[].idstring (uuid)no
experiments[].variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiments[].variants[].namestringno
experiments[].variants[].is_controlbooleanno
experiments[].variants[].weightintegerno
experiments[].variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiments[].variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiments[].variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiments[].variants[].overrides.min_gap_secondsintegerno
experiments[].variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiments[].created_atstring (date-time)no
experiments[].updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

429 — ApiKeyRateLimited ​

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

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

Create an A/B experiment (draft) ​

http
POST /v1/campaigns/{id}/experiments

Creates 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

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
primary_metriccontact_rate · promise_rateno
variantsarray of ExperimentVariantWriteno
variants[].namestringyes
variants[].weightintegeryesShare of the traffic, relative to the sum of the weights.
variants[].is_controlbooleannoExactly one variant of the experiment is the control.
variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
variants[].overrides.script.fieldtts_prompt · voicemail_message · sms_bodyyes
variants[].overrides.script.textstringyes
variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
variants[].overrides.window.startstringyes
variants[].overrides.window.endstringyes
variants[].overrides.min_gap_secondsintegerno
variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.

Responses

201 ​

The draft experiment.

FieldTypeRequiredDescription
experimentobject (Experiment)yes
experiment.idstring (uuid)no
experiment.campaign_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metriccontact_rate · promise_rateno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.variantsarray of ExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiment.variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiment.variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiment.variants[].overrides.min_gap_secondsintegerno
experiment.variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

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

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

Responses

200 ​

The experiment with its variants.

FieldTypeRequiredDescription
experimentobject (Experiment)yes
experiment.idstring (uuid)no
experiment.campaign_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metriccontact_rate · promise_rateno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.variantsarray of ExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiment.variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiment.variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiment.variants[].overrides.min_gap_secondsintegerno
experiment.variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

429 — ApiKeyRateLimited ​

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

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

Edit a draft A/B experiment ​

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

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

Request body (JSON, required)

FieldTypeRequiredDescription
namestringno
primary_metriccontact_rate · promise_rateno
variantsarray of ExperimentVariantWriteno
variants[].namestringyes
variants[].weightintegeryesShare of the traffic, relative to the sum of the weights.
variants[].is_controlbooleannoExactly one variant of the experiment is the control.
variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
variants[].overrides.script.fieldtts_prompt · voicemail_message · sms_bodyyes
variants[].overrides.script.textstringyes
variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
variants[].overrides.window.startstringyes
variants[].overrides.window.endstringyes
variants[].overrides.min_gap_secondsintegerno
variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.

Responses

200 ​

The edited experiment.

FieldTypeRequiredDescription
experimentobject (Experiment)yes
experiment.idstring (uuid)no
experiment.campaign_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metriccontact_rate · promise_rateno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.variantsarray of ExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiment.variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiment.variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiment.variants[].overrides.min_gap_secondsintegerno
experiment.variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
experiment_idpathstring (uuid)yesId 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}/results

Recomputed 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

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

Responses

200 ​

The results of the experiment.

FieldTypeRequiredDescription
resultsobject (ExperimentResults)yes
results.experimentobjectyes
results.experiment.idstring (uuid)no
results.experiment.campaign_idstring (uuid)no
results.experiment.namestringno
results.experiment.statedraft · running · endedno
results.experiment.primary_metriccontact_rate · promise_rateno
results.experiment.started_atstring,null (date-time)no
results.experiment.ended_atstring,null (date-time)no
results.variantsarray of ExperimentVariantResultsyes
results.variants[].variant_idstring (uuid)no
results.variants[].namestringno
results.variants[].is_controlbooleanno
results.variants[].weightintegerno
results.variants[].assigned_debtsintegernoDistinct debts with an attempt in this variant (the unit of randomization).
results.variants[].attemptsintegerno
results.variants[].not_placedintegernoAttempts that never left (outside every denominator).
results.variants[].placedintegernoattempts minus not_placed; the denominator of the rates.
results.variants[].contactsintegernoPlaced 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[].promisesintegernoPlaced 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[].abandonedintegernoContacts that ended with no agent to take them.
results.variants[].successesintegernocontacts or promises, per the experiment's primary_metric.
results.variants[].contact_ratenumber,nullnocontacts / placed; null when nothing was placed.
results.variants[].promise_ratenumber,nullnopromises / placed; null when nothing was placed and always null for an SMS or email campaign.
results.variants[].abandon_ratenumber,nullnoabandoned / contacts; null when there were no contacts and always null for an SMS or email campaign.
results.unattributed_attemptsintegeryesAttempts 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.verdictobject (ExperimentVerdict)yes
results.verdict.statusinsufficient_data · no_significant_difference · winneryes
results.verdict.winnernull | objectyes
results.verdict.withheld_bynull | allocation_warningyesSet when a would-be winner was withheld because of the allocation warning.
results.verdict.comparisonsarray of objectyesOne entry per challenger; empty while the sample floor is not met.
results.verdict.comparisons[].variant_idstring (uuid)no
results.verdict.comparisons[].ratenumberno
results.verdict.comparisons[].differencenumbernoChallenger minus control.
results.verdict.comparisons[].znumberno
results.verdict.comparisons[].p_valuenumberno
results.verdict.comparisons[].adjusted_p_valuenumberno
results.verdict.comparisons[].ci_lownumberno
results.verdict.comparisons[].ci_highnumberno
results.verdict.comparisons[].significantbooleanno
results.verdict.sample_floorobjectyes
results.verdict.sample_floor.min_placedintegerno
results.verdict.sample_floor.min_successesintegerno
results.verdict.sample_floor.metbooleanno
results.allocation_warningbooleanyesThe observed split of assigned debts deviates from the weights (chi-square, alpha 0.001). A winner is never emitted while it is true.
results.allocationobjectyes
results.allocation.chi_squarenumberno
results.allocation.degrees_of_freedomintegerno
results.allocation.p_valuenumberno
results.allocation.warningbooleanno

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}/start

draft → 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

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

Responses

200 ​

The experiment, now running.

FieldTypeRequiredDescription
experimentobject (Experiment)yes
experiment.idstring (uuid)no
experiment.campaign_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metriccontact_rate · promise_rateno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.variantsarray of ExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiment.variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiment.variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiment.variants[].overrides.min_gap_secondsintegerno
experiment.variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

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}/stop

running → ended (terminal). Anything else → 409.

Parameters

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

Responses

200 ​

The experiment, now ended.

FieldTypeRequiredDescription
experimentobject (Experiment)yes
experiment.idstring (uuid)no
experiment.campaign_idstring (uuid)no
experiment.namestringno
experiment.statedraft · running · endedno
experiment.primary_metriccontact_rate · promise_rateno
experiment.started_atstring,null (date-time)no
experiment.ended_atstring,null (date-time)no
experiment.variantsarray of ExperimentVariantno
experiment.variants[].idstring (uuid)no
experiment.variants[].positionintegernoOrder Assignment.pick/3 walks the variants in (0-based).
experiment.variants[].namestringno
experiment.variants[].is_controlbooleanno
experiment.variants[].weightintegerno
experiment.variants[].overridesobject (ExperimentVariantOverrides)noWhat a variant changes about its campaign: a CLOSED list. Any other key is refused with 422 (never ignored).
experiment.variants[].overrides.scriptobjectnoGoes through the campaign's own write validators (an sms_body needs «STOP»).
experiment.variants[].overrides.windowobjectnoA SUBSET of the campaign's calling window; it never widens it.
experiment.variants[].overrides.min_gap_secondsintegerno
experiment.variants[].overrides.email_template_idstring (uuid)noAn APPROVED template of the tenant.
experiment.created_atstring (date-time)no
experiment.updated_atstring (date-time)no

401 — Unauthorized ​

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

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

404 — NotFound ​

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

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

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}/dial

An 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

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

Request body (JSON, required)

FieldTypeRequiredDescription
debt_idstring (uuid)yes
agent_idstring (uuid)noThe 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.

FieldTypeRequiredDescription
statusdialing · blockedyes
call_idstring (uuid)noPresent when status is dialing.
reasontcpa_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_consentnoThe 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/manual

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

FieldTypeRequiredDescription
numberstringyesThe typed destination, E.164 (+13125550100).
agent_idstring (uuid)noThe 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.

FieldTypeRequiredDescription
statusdialing · blockedyes
call_idstring (uuid)noPresent when status is dialing.
reasontcpa_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_consentnoThe 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-lead

Parameters

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

Responses

200 ​

The next dialable lead, or null when the feed is empty.

FieldTypeRequiredDescription
leadPreviewLead | nullyes
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-preflight

Runs 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

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

Responses

200 ​

Preflight aggregate

FieldTypeRequiredDescription
campaign_idstring (uuid)yes
unknown_state_policystrict · federal_fallbackyesPosture of the state matrix for a consumer_state outside its canonical shape, per the tenant flag.
tenant_gatesobjectyesPosture of each gate for this tenant. Only state_matrix has an off switch today.
tenant_gates.dncactiveno
tenant_gates.consentactiveno
tenant_gates.quiet_hoursactiveno
tenant_gates.reg_factiveno
tenant_gates.state_matrixactive · federal_fallbackno
totalsobjectyes
totals.leadsintegernoExact size of the dialable population.
totals.evaluatedintegernoLeads the engine was run over (≤ leads).
totals.eligibleintegerno
totals.suppressedintegerno
totals.unknown_stateintegernoEvaluated leads with an empty us_state.
truncatedbooleanyestrue when evaluated < leads (evaluation bound hit).
statesobjectyesKeyed by the RAW us_state the engine sees ("unknown" for the empty string).
list_healthobjectyesPer-signal health of the evaluated leads, without short-circuit (core#1181).
list_health.signalsobjectyes
list_health.signals.consentobject (PreflightSignal)yesOne 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.statusok · blocking · no_verificableyes
list_health.signals.consent.blockedintegeryes
list_health.signals.consent.no_verificableintegeryes
list_health.signals.consent.rulesobjectyesBlocked leads per rule id (for state, the state-matrix label the lead falls under).
list_health.signals.stateobject (PreflightSignal)yesOne 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.statusok · blocking · no_verificableyes
list_health.signals.state.blockedintegeryes
list_health.signals.state.no_verificableintegeryes
list_health.signals.state.rulesobjectyesBlocked leads per rule id (for state, the state-matrix label the lead falls under).
list_health.signals.dncobject (PreflightSignal)yesOne 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.statusok · blocking · no_verificableyes
list_health.signals.dnc.blockedintegeryes
list_health.signals.dnc.no_verificableintegeryes
list_health.signals.dnc.rulesobjectyesBlocked leads per rule id (for state, the state-matrix label the lead falls under).
list_health.signals.rndobject (PreflightSignal)yesOne 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.statusok · blocking · no_verificableyes
list_health.signals.rnd.blockedintegeryes
list_health.signals.rnd.no_verificableintegeryes
list_health.signals.rnd.rulesobjectyesBlocked leads per rule id (for state, the state-matrix label the lead falls under).
list_health.dnc_snapshotsobjectyesThe three global DNC lists, each against the 31-day ceiling.
list_health.dnc_snapshots.federalobject (PreflightDncSnapshot)yes
list_health.dnc_snapshots.federal.statusprovisioned · stale · absentyes
list_health.dnc_snapshots.federal.age_daysinteger,nullyesDays since the snapshot was imported; null when absent.
list_health.dnc_snapshots.federal.max_age_daysintegeryesFreshness ceiling for this list.
list_health.dnc_snapshots.federal.sourcestringyesProvenance string of the snapshot (none when absent).
list_health.dnc_snapshots.federal.record_countinteger,nullyes
list_health.dnc_snapshots.stateobject (PreflightDncSnapshot)yes
list_health.dnc_snapshots.state.statusprovisioned · stale · absentyes
list_health.dnc_snapshots.state.age_daysinteger,nullyesDays since the snapshot was imported; null when absent.
list_health.dnc_snapshots.state.max_age_daysintegeryesFreshness ceiling for this list.
list_health.dnc_snapshots.state.sourcestringyesProvenance string of the snapshot (none when absent).
list_health.dnc_snapshots.state.record_countinteger,nullyes
list_health.dnc_snapshots.internal_globalobject (PreflightDncSnapshot)yes
list_health.dnc_snapshots.internal_global.statusprovisioned · stale · absentyes
list_health.dnc_snapshots.internal_global.age_daysinteger,nullyesDays since the snapshot was imported; null when absent.
list_health.dnc_snapshots.internal_global.max_age_daysintegeryesFreshness ceiling for this list.
list_health.dnc_snapshots.internal_global.sourcestringyesProvenance string of the snapshot (none when absent).
list_health.dnc_snapshots.internal_global.record_countinteger,nullyes
list_health.dnc_state_registriesobjectyesEvery declared per-state DNC registry, keyed by state code, each against its own ceiling.
list_health.rnd_providerobjectyes
list_health.rnd_provider.statusdeclared · stub · no_verificableyesstub 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.providerstring,nullyesName 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}/stats

running: 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

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

Responses

200 ​

Campaign state + live stats.

FieldTypeRequiredDescription
campaign_idstring (uuid)yes
statedraft · running · paused · completed · archived · suspended_balance · exhaustedyes
runningbooleanyes
statsVoiceRunnerStats | SmsRunnerStats | nullyes
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-results

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
question_idquerystringnoNarrow to one question (non-empty string). Omitted = every question.
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

Digit distribution (JSON) or the CSV artifact.

FieldTypeRequiredDescription
campaign_idstring (uuid)yes
fromstring (date-time)yes
tostring (date-time)yes
question_idstring | nullyesThe filter that was applied; null = every question.
totalintegeryes
distributionarray of objectyesOne row per digit pressed at least once, sorted by digit.
distribution[].digitstringyes
distribution[].countintegeryes
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}/abandonment

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

The report (JSON) or the CSV artifact.

FieldTypeRequiredDescription
campaign_idstring (uuid)yes
measured_atstring (date-time)yes
period_days30yes
ceiling_percent3yes
anchored_atstring (date-time) | nullyesThe campaign's first placed dial; null = no dial yet (and no period).
predicateobjectyes
predicate.countsarray of no_agent · ai_stream_lost · agent_bridge_failed · handoff_failed · late_bridgeyesThe reasons the rate counts as abandoned.
predicate.reported_apartarray of no_agent · ai_stream_lost · agent_bridge_failed · handoff_failed · late_bridgeyesReasons reported per period and NOT counted in the rate.
periodsarray of AbandonmentPeriodyesOne entry per 30-day period with at least one placed dial, oldest first.
periods[].indexintegeryes[anchored_at + 30·index days, + 30 days).
periods[].starts_atstring (date-time)yes
periods[].ends_atstring (date-time)yesExclusive.
periods[].completebooleanyesends_at is not after measured_at.
periods[].placedintegeryes
periods[].answeredintegeryes
periods[].answered_machineintegeryesAnswered with an AMD machine verdict.
periods[].answered_liveintegeryesThe denominator: answered - answered_machine.
periods[].abandonedintegeryesThe numerator; the sum of abandoned_by_reason.
periods[].abandoned_by_reasonobjectyesOne 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_failedintegeryesVoice-AI handoffs with no seat; not in the rate.
periods[].late_bridgeintegeryesLive answers not counted as abandoned whose consumer leg was bridged more than 2 s after answered_at (core#1426); not in the rate.
periods[].ratenumber | nullyesabandoned / answered_live, 4 decimals; null with no live answer.
periods[].exceeds_ceilingbooleanyesabandoned * 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-skips

The 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
reasonquerydebt_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_tenantnoNarrow samples to one reason. Omitted = every reason. reasons is never narrowed.
limitqueryintegernoSize of samples (default 20, max 100).

Responses

200 ​

Per-reason numbers and the latest rows.

FieldTypeRequiredDescription
campaign_idstring (uuid)yes
total_leadsintegeryesDistinct debts discarded for ANY reason (a debt with two reasons counts once).
reasonsarray of EmailLeadSkipReasonCountyesEvery reason of the closed vocabulary, zeros included, in vocabulary order.
reasons[].reasondebt_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_tenantyes
reasons[].leadsintegeryesDistinct debts discarded for this reason.
reasons[].daysintegeryesUTC days with at least one row for this reason.
reasons[].first_atstring,null (date-time)yes
reasons[].last_atstring,null (date-time)yes
samplesarray of EmailLeadSkipSampleyes
samples[].debt_idstring (uuid)yes
samples[].reasondebt_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_tenantyes
samples[].occurred_atstring (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/preview

Dashboard-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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource UUID. Malformed or cross-tenant ids read as 404.
dispositionsquerystringnoComma-separated list matched against each debt's LATEST disposition.
max_attemptsqueryintegerno
last_attempt_older_than_daysqueryintegerno
amd_verdictsquerystringnoComma-separated AMD verdicts matched against each debt's latest placed attempt.
segment_idquerystring (uuid)noA 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.

FieldTypeRequiredDescription
matchingintegeryes
excluded_by_complianceintegeryesMatching debts the engine would block RIGHT NOW (informational).
debt_idsarray 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/preview

Same dry-run as the GET, filters in the JSON body (B1 handoff shape).

Parameters

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

Request body (JSON, required)

json
{
  "max_attempts": 1
}

Responses

200 ​

Dry-run result.

FieldTypeRequiredDescription
matchingintegeryes
excluded_by_complianceintegeryesMatching debts the engine would block RIGHT NOW (informational).
debt_idsarray 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}/rewind

Sets 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

NameInTypeRequiredDescription
idpathstring (uuid)yesResource 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.

FieldTypeRequiredDescription
requeuedintegeryes
callbacks_cancelledintegeryes
kept_closedintegeryesMatching 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"
  }
}

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