Skip to content

Stats & usage ​

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

Operational counters, blocked-dial rollups, usage metering.

Endpoints ​

MethodPathSummary
GET/v1/stats/summaryToday's operational counters (UTC day)
GET/v1/stats/contact-windowsContact rate by the consumer's local hour, best window, phones per person
GET/v1/stats/promisesPTP pipeline summary, tenant totals and per campaign
GET/v1/stats/penetrationPerson-level penetration analytics
GET/v1/stats/abandonmentDurable abandonment report of every campaign, in one response
GET/v1/stats/blockedDurable blocked-dial rollup
GET/v1/stats/capacityTenant capacity ceilings + live usage (ADR #12)
GET/v1/stats/ai-usageVoice-AI minute metering (invoicing read)
GET/v1/stats/sms-usageSMS segment metering (invoicing read)

Today's operational counters (UTC day) ​

http
GET /v1/stats/summary

Computed from the append-only CDR + live seat registry — reconstructible truth, not cached state.

Responses

200 ​

Today's counters.

FieldTypeRequiredDescription
datestring (date)no
window"utc_day"no
calls_todayintegerno
connected_todayintegerno
connect_ratenumberno
agents_onlineintegerno
agents_availableintegerno
campaigns_runningintegerno
json
{
  "date": "2026-07-05",
  "window": "utc_day",
  "calls_today": 412,
  "connected_today": 67,
  "connect_rate": 0.1626,
  "agents_online": 5,
  "agents_available": 3,
  "campaigns_running": 2
}

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

503 ​

The counters could not be read within the statement timeout (report_timeout).

Contact rate by the consumer's local hour, best window, phones per person ​

http
GET /v1/stats/contact-windows

For every hour of the day (0-23) in the CONSUMER's local time: how many attempts were placed, how many reached a person (answered_human) and the rate. The hour is the consumer's, not the tenant's or UTC's: it uses the zone the quiet-hours gate froze into the attempt when it was placed (the number's area-code zones plus the contact's declared zone), summer time included. Derived on read from the append-only CDR; no counter is stored.

An attempt is one original platform-placed outbound call. Inbound calls, external-transfer legs and rows the platform never placed are not attempts. A contact is an attempt with at least one answered_human outcome.

An attempt whose local hour cannot be known is counted in unknown, never spread over the hours: no_zone (nothing frozen, or a zone the time-zone database does not know) and ambiguous_zone (an area code that spans several zones, or a declared zone that disagrees with it). unknown.attempts is the sum of both reasons.

best_window is the two consecutive local hours with the highest contact rate, and it is named only when EACH of the two hours holds at least min_attempts attempts (tenant setting contact_window_min_attempts, default 50). Otherwise it is null and best_window_reason says why (insufficient_attempts, or no_resolved_attempts when no attempt had a known local hour); an hour is never invented from a handful of calls. Ties go to the earlier hour; the window does not wrap past midnight. Both hours must also lie inside the legal calling window [08:00, 21:00), so a window is never recommended in a time the platform would refuse to dial; hours outside it still appear in hours but cannot anchor best_window.

weekdays is the same contact rate on the consumer's local ISO weekday axis (1 = Monday ... 7 = Sunday). applied says whether the tenant's best_time_ranking_enabled knob is on (the dialer feed orders leads by this data), source the level that speaks for it (campaign, portfolio or none), and weights the weight table the dialer applies to each (weekday, local hour) cell inside the legal window while applied is true, with the level that spoke for that cell; when applied is false the dialer keeps the neutral order and source/weights show what it would apply with the knob on. weights always comes from the trailing 90 days and is not narrowed by from/to.

phones_per_person is the mean number of distinct phone numbers per person (consumer_ref) among the tenant's active accounts (any state but paid, settled or closed). A person with an active account and no phone counts as 0. It is tenant-wide: from, to and campaign_id do not narrow it. average is null when there is no active person.

from/to default to the trailing 30 days; a window wider than 90 days, or with from not before to, is 422. An unknown campaign (or another tenant's) is 404, never an all-zero report.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
campaign_idquerystring (uuid)no

Responses

200 ​

Hourly contact rates, the best window and phones per person.

FieldTypeRequiredDescription
fromstring (date-time)yes
tostring (date-time)yes
campaign_idstring (uuid) | nullyes
basis"consumer_local_hour"yes
min_attemptsintegeryesThe floor each hour of best_window must meet (contact_window_min_attempts).
hoursarray of ContactWindowHouryesAlways 24 rows, hour 0 to 23, in the consumer's local time.
hours[].hourintegeryes
hours[].attemptsintegeryes
hours[].contactsintegeryes
hours[].contact_ratenumber | nullyescontacts / attempts, four decimals; null when there were no attempts.
weekdaysarray of ContactWindowWeekdaynoAlways 7 rows, ISO weekday 1 (Monday) to 7 (Sunday), in the consumer's local time. Additive (core#1980); optional for clients written before it.
weekdays[].weekdayintegeryesISO weekday, 1 = Monday.
weekdays[].attemptsintegeryes
weekdays[].contactsintegeryes
weekdays[].contact_ratenumber | nullyescontacts / attempts, four decimals; null when there were no attempts.
appliedbooleannoWhether the tenant's best_time_ranking_enabled knob is on, i.e. whether the dialing feed orders leads by this data. Additive (core#1980).
sourcecampaign · portfolio · nonenoThe level that speaks for weights — the campaign's own history, the tenant's portfolio, or none (no evidence, neutral order). Additive (core#1980).
weightsarray of ContactWindowWeightnoThe weight the dialer applies to each (weekday, local hour) cell inside the legal window [08:00, 21:00) while applied is true (with applied false it keeps the neutral order and this is what it would apply) — 7 x 13 rows, or empty if it could not be computed. Trailing 90 days; not narrowed by from/to. Additive (core#1980).
weights[].weekdayintegeryes
weights[].hourintegeryes
weights[].weightnumberyes1.0 is neutral.
weights[].sourcecampaign · portfolio · noneyes
weights[].attemptsintegeryesAttempts in this cell at the level that spoke; 0 when source is none.
weights[].contactsintegeryes
unknownobjectyesAttempts whose local hour is not knowable. Never redistributed over hours.
unknown.attemptsintegeryes
unknown.contactsintegeryes
unknown.no_zoneintegeryesAttempts with no usable frozen zone.
unknown.ambiguous_zoneintegeryesAttempts whose area code spans several zones or disagrees with the declared one.
best_windowContactWindow | nullyes
best_window_reasoninsufficient_attempts · no_resolved_attempts | nullyesWhy best_window is null; null when a window is named.
phones_per_personobjectyes
phones_per_person.personsintegeryesDistinct consumer_ref with an active account.
phones_per_person.phonesintegeryesSum of distinct phones per person.
phones_per_person.averagenumber | nullyesphones / persons, two decimals; null with no active person.
json
{
  "from": "2026-09-04T00:00:00Z",
  "to": "2026-10-04T00:00:00Z",
  "campaign_id": null,
  "basis": "consumer_local_hour",
  "min_attempts": 50,
  "hours": [
    {
      "hour": 9,
      "attempts": 120,
      "contacts": 18,
      "contact_rate": 0.15
    },
    {
      "hour": 10,
      "attempts": 140,
      "contacts": 28,
      "contact_rate": 0.2
    },
    {
      "hour": 11,
      "attempts": 0,
      "contacts": 0,
      "contact_rate": null
    }
  ],
  "weekdays": [
    {
      "weekday": 1,
      "attempts": 90,
      "contacts": 15,
      "contact_rate": 0.1667
    }
  ],
  "unknown": {
    "attempts": 14,
    "contacts": 1,
    "no_zone": 9,
    "ambiguous_zone": 5
  },
  "applied": true,
  "source": "portfolio",
  "weights": [
    {
      "weekday": 1,
      "hour": 9,
      "weight": 1.2,
      "source": "portfolio",
      "attempts": 120,
      "contacts": 18
    },
    {
      "weekday": 1,
      "hour": 10,
      "weight": 1,
      "source": "none",
      "attempts": 0,
      "contacts": 0
    }
  ],
  "best_window": {
    "start_hour": 9,
    "end_hour": 11,
    "attempts": 260,
    "contacts": 46,
    "contact_rate": 0.1769
  },
  "best_window_reason": null,
  "phones_per_person": {
    "persons": 4,
    "phones": 7,
    "average": 1.75
  }
}

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 ​

The window is wider than 90 days, or from is not before to.

json
{
  "error": {
    "code": "unprocessable",
    "message": "the window must not exceed 90 days"
  }
}

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

PTP pipeline summary, tenant totals and per campaign ​

http
GET /v1/stats/promises

Currencies never sum together; kept_rate_30d is null when nothing resolved in the window.

by_campaign has one row per campaign with at least one promise, derived at read time through the attempt the promise was captured on (promises.call_attempt_id -> call_attempts.campaign_id); no counter is stored. A promise with no attempt, or whose attempt has no campaign, is grouped in the row with campaign_id: null — never dropped — so the rows always add up to the top-level totals.

With campaign_id every figure narrows to that campaign. An unknown campaign (or another tenant's) is 404, never an all-zero summary.

The kept rate is client-reported. The platform does not learn on its own whether a consumer paid: a promise only becomes kept or broken when the client says so through PATCH /v1/promises/{id}. A client that never reports leaves its promises pending and the rate null.

Parameters

NameInTypeRequiredDescription
campaign_idquerystring (uuid)no

Responses

200 ​

Pipeline summary.

json
{
  "campaign_id": null,
  "open": {
    "count": 4,
    "amount_cents": {
      "USD": 50000,
      "MXN": 120000
    }
  },
  "resolved_30d": {
    "kept": 3,
    "broken": 1
  },
  "kept_rate_30d": 0.75,
  "window_days": 30,
  "by_campaign": [
    {
      "campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "open": {
        "count": 3,
        "amount_cents": {
          "USD": 50000,
          "MXN": 0
        }
      },
      "resolved_30d": {
        "kept": 3,
        "broken": 1
      },
      "kept_rate_30d": 0.75
    },
    {
      "campaign_id": null,
      "open": {
        "count": 1,
        "amount_cents": {
          "USD": 0,
          "MXN": 120000
        }
      },
      "resolved_30d": {
        "kept": 0,
        "broken": 0
      },
      "kept_rate_30d": null
    }
  ]
}

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

Person-level penetration analytics ​

http
GET /v1/stats/penetration

Person-level dedup (each debt counts once): attempted = at least one original attempt, reached = any non-failed disposition or conversation, effective_contact = answered_human or conversation. campaign_ids is optional (max 100; campaign_id= also accepted): without it the report covers EVERY campaign of the calling tenant, with one person-level total (an account in two campaigns counts once), so a tenant with more than 100 campaigns reads its whole penetration in one call. With the list the response is the same as ever.

granularity=day adds by_day: one row per LOCAL day of the window and per campaign, the day cut in the IANA zone tz names, summer time included (CET is not a fixed +01:00). The grid is dense (a quiet day is a row of zeros) and is derived on read from the append-only CDR. With granularity=day, tz is REQUIRED — there is no tenant zone to default to — and a missing or non-IANA tz is 422, never a silent UTC cut; from and to are required too and the window is capped at 31 days (400 otherwise, the /v1/reports/attempts criterion). totals and by_campaign stay the range numbers: a person-level count does not add up across days. Without granularity (or with total) the response is unchanged and tz is not read.

window=7d|30d|90d|all answers the same totals / by_campaign over a window RELATIVE to now, served from a short-lived in-memory snapshot derived from the append-only CDR (nothing is written when dialing). The response adds window and as_of, the instant the numbers were computed; a snapshot older than the server's max_age (default 60 s) is never served, the report is computed live instead. window is exclusive with from / to and with granularity=day (422); without it the response is unchanged.

Parameters

NameInTypeRequiredDescription
campaign_idsquerystringnoComma-separated campaign UUIDs (max 100). Omit it (or send it empty) for every campaign of the tenant.
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
windowquery7d · 30d · 90d · allnoRelative window served from the derived snapshot. Exclusive with from/to and with granularity=day.
granularityquerytotal · daynototal (default) or day (adds by_day; requires tz, from and to).
tzquerystringnoIANA time zone the day is cut in. Required with granularity=day; ignored otherwise.

Responses

200 ​

Penetration totals + per-campaign rows (+ by_day with granularity=day).

FieldTypeRequiredDescription
totalsobject (PenetrationRow)no
totals.campaign_idstring (uuid) | nullno
totals.unique_accountsintegerno
totals.unique_consumersintegerno
totals.attemptedintegerno
totals.reachedintegerno
totals.effective_contactintegerno
totals.attempted_pctnumberno
totals.reached_pctnumberno
totals.effective_contact_pctnumberno
by_campaignarray of PenetrationRowno
by_campaign[].campaign_idstring (uuid) | nullno
by_campaign[].unique_accountsintegerno
by_campaign[].unique_consumersintegerno
by_campaign[].attemptedintegerno
by_campaign[].reachedintegerno
by_campaign[].effective_contactintegerno
by_campaign[].attempted_pctnumberno
by_campaign[].reached_pctnumberno
by_campaign[].effective_contact_pctnumberno
window7d · 30d · 90d · allnoPresent only with window=.
as_ofstring (date-time)noPresent only with window=: when the numbers were computed. Never older than the server's snapshot max_age.
granularitydaynoPresent only with granularity=day.
tzstringnoThe zone the days were cut in. Present only with granularity=day.
by_dayarray of PenetrationDayRownoPresent only with granularity=day. Every local day the window touches × every campaign with a universe, ordered by day then campaign; zeros included. The first and last day are partial when from/to are not local midnights. Counts add up across campaigns (a debt belongs to one campaign), not across days.
by_day[].daystring (date)no
by_day[].campaign_idstring (uuid)no
by_day[].unique_accountsintegernoThe campaign's universe — the same number its by_campaign row reports.
by_day[].attemptedintegerno
by_day[].reachedintegerno
by_day[].effective_contactintegerno
by_day[].attempted_pctnumbernoOver unique_accounts.
by_day[].reached_pctnumbernoOver unique_accounts.
by_day[].effective_contact_pctnumbernoOver unique_accounts.
json
{
  "totals": {
    "campaign_id": null,
    "unique_accounts": 4,
    "unique_consumers": 4,
    "attempted": 2,
    "reached": 1,
    "effective_contact": 1,
    "attempted_pct": 50,
    "reached_pct": 25,
    "effective_contact_pct": 25
  },
  "by_campaign": [
    {
      "campaign_id": "3f2c1b0a-9d8e-4c7b-a6f5-4e3d2c1b0a99",
      "unique_accounts": 4,
      "unique_consumers": 4,
      "attempted": 2,
      "reached": 1,
      "effective_contact": 1,
      "attempted_pct": 50,
      "reached_pct": 25,
      "effective_contact_pct": 25
    }
  ]
}

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

422 ​

granularity=day without tz, or with a tz that is not an IANA time zone; or window that is unknown or combined with from/to or granularity=day.

json
{
  "error": {
    "code": "unprocessable",
    "message": "tz is required with granularity=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"
  }
}

Durable abandonment report of every campaign, in one response ​

http
GET /v1/stats/abandonment

The same report GET /v1/campaigns/{id}/abandonment gives, for EVERY campaign of the calling tenant at once (the console used to ask once per campaign). Served from a short-lived in-memory snapshot derived from the append-only CDR (nothing is written when dialing): as_of is the instant the numbers were computed, and a snapshot older than the server's max_age (default 60 s) is never served, the report is computed live instead. The per-campaign route stays live and is the record; this one is the overview.

Responses

200 ​

One AbandonmentReport per campaign, ordered by campaign name.

FieldTypeRequiredDescription
as_ofstring (date-time)yes
campaignsarray of AbandonmentReportyes
campaigns[].campaign_idstring (uuid)yes
campaigns[].measured_atstring (date-time)yes
campaigns[].period_days30yes
campaigns[].ceiling_percent3yes
campaigns[].anchored_atstring (date-time) | nullyesThe campaign's first placed dial; null = no dial yet (and no period).
campaigns[].predicateobjectyes
campaigns[].predicate.countsarray of no_agent · ai_stream_lost · agent_bridge_failed · handoff_failed · late_bridgeyesThe reasons the rate counts as abandoned.
campaigns[].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.
campaigns[].periodsarray of AbandonmentPeriodyesOne entry per 30-day period with at least one placed dial, oldest first.
campaigns[].periods[].indexintegeryes[anchored_at + 30·index days, + 30 days).
campaigns[].periods[].starts_atstring (date-time)yes
campaigns[].periods[].ends_atstring (date-time)yesExclusive.
campaigns[].periods[].completebooleanyesends_at is not after measured_at.
campaigns[].periods[].placedintegeryes
campaigns[].periods[].answeredintegeryes
campaigns[].periods[].answered_machineintegeryesAnswered with an AMD machine verdict.
campaigns[].periods[].answered_liveintegeryesThe denominator: answered - answered_machine.
campaigns[].periods[].abandonedintegeryesThe numerator; the sum of abandoned_by_reason.
campaigns[].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.
campaigns[].periods[].handoff_failedintegeryesVoice-AI handoffs with no seat; not in the rate.
campaigns[].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.
campaigns[].periods[].ratenumber | nullyesabandoned / answered_live, 4 decimals; null with no live answer.
campaigns[].periods[].exceeds_ceilingbooleanyesabandoned * 100 > 3 * answered_live.

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

Durable blocked-dial rollup ​

http
GET /v1/stats/blocked

Reads the durable, APPEND-ONLY gate_blocks evidence: one row per (debt, gate, campaign, UTC day) the compliance engine REFUSED to dial. Every number is a count of distinct refusals, never an estimate. Defaults: trailing 30 UTC days.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
campaign_idquerystring (uuid)no

Responses

200 ​

Blocked-dial rollup.

FieldTypeRequiredDescription
fromstring (date-time)no
tostring (date-time)no
campaign_idstring (uuid) | nullno
totalintegerno
by_gateobjectnoKeys are the ComplianceGate vocabulary.
dailyarray of objectno
daily[].datestring (date)no
daily[].totalintegerno
daily[].by_gateobjectno
json
{
  "from": "2026-06-05T00:00:00Z",
  "to": "2026-07-05T00:00:00Z",
  "campaign_id": null,
  "total": 5,
  "by_gate": {
    "quiet_hours": 4,
    "dnc_listed": 1
  },
  "daily": [
    {
      "date": "2026-06-01",
      "total": 3,
      "by_gate": {
        "quiet_hours": 2,
        "dnc_listed": 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"
  }
}

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

Tenant capacity ceilings + live usage (ADR #12) ​

http
GET /v1/stats/capacity

The tenant's admin-set capacity ceilings (max_cps, max_concurrent_channels; null = no ceiling) plus live usage read from the capacity gate. limits: null = the ceilings could not be read AND were never cached — the exact condition the gate fails CLOSED on; surfaced honestly, never fabricated as "unlimited".

supervision is a SECOND, independent ceiling (ADR #37): live-call supervision legs originate outside the dial choke point, so they are counted in their own quota. Neither lane can exhaust the other — the dialing floor can be at its ceiling while supervision still admits, and vice versa.

Responses

200 ​

Capacity ceilings + live usage.

FieldTypeRequiredDescription
limitsobject | nullnoAdmin-set ceilings, or null when the gate fails closed (ceilings unreadable and never cached). Each ceiling is null when unset.
channels_in_useintegerno
dials_last_secondintegerno
supervisionobjectnoThe SECOND ceiling (ADR #37): concurrent live-call supervision sessions, counted and bounded separately from the dialing lane so neither can starve the other. limit is the deploy-time max_supervision_channels; in_use counts (call, supervisor) sessions, so a listen → barge switch on one call counts once.
supervision.limitintegerno
supervision.in_useintegerno
json
{
  "limits": {
    "max_cps": 10,
    "max_concurrent_channels": 50
  },
  "channels_in_use": 3,
  "dials_last_second": 2,
  "supervision": {
    "limit": 20,
    "in_use": 1
  }
}

401 — Unauthorized ​

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

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

429 — ApiKeyRateLimited ​

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

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

Voice-AI minute metering (invoicing read) ​

http
GET /v1/stats/ai-usage

Off the durable exactly-once ai_usage feed (one row per answered AI call, billed EXACTLY ONCE on call_uuid). Each row snapshots the per-minute rate it was metered under — changing the configured rate never reprices history. The window is half-open — from inclusive, to EXCLUSIVE — so a call answered exactly at a billing cut belongs to one period only (the one starting at that instant), never to both sides of the cut. ?format=csv (or Accept: text/csv) returns the billing artifact with its own X-Artifact-SHA256.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
campaign_idquerystring (uuid)no
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

Metering stats (JSON) or the CSV billing artifact.

FieldTypeRequiredDescription
fromstring (date-time)no
tostring (date-time)no
campaign_idstring (uuid) | nullno
minute_rate_usdnumberno
callsintegerno
billable_secondsintegerno
billable_minutesnumberno
estimated_cost_usdnumberno
dailyarray of objectno
daily[].datestring (date)no
daily[].callsintegerno
daily[].billable_secondsintegerno
daily[].billable_minutesnumberno
daily[].minute_rate_usdnumberno
daily[].estimated_cost_usdnumberno
json
{
  "from": "2026-06-05T00:00:00Z",
  "to": "2026-07-05T00:00:00Z",
  "campaign_id": null,
  "minute_rate_usd": 0.25,
  "calls": 3,
  "billable_seconds": 270,
  "billable_minutes": 4.5,
  "estimated_cost_usd": 1.13,
  "daily": [
    {
      "date": "2026-06-01",
      "calls": 2,
      "billable_seconds": 180,
      "billable_minutes": 3,
      "minute_rate_usd": 0.25,
      "estimated_cost_usd": 0.75
    }
  ]
}

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

429 — ApiKeyRateLimited ​

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

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

SMS segment metering (invoicing read) ​

http
GET /v1/stats/sms-usage

Straight off the unified ledger: sms_segment billable events, exactly-once at provider ACCEPT, each priced at its region-rate snapshot. JSON, or ?format=csv / Accept: text/csv for the billing artifact.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted).
toquerystring (date-time)noRFC 3339 upper bound.
campaign_idquerystring (uuid)no
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

Metering stats (JSON) or the CSV billing artifact.

FieldTypeRequiredDescription
fromstring (date-time)no
tostring (date-time)no
campaign_idstring (uuid) | nullno
segment_rates_usdobjectnoPer-region rate currently in force (what new sends will snapshot).
messagesintegerno
segmentsstringnoDecimal as string.
amount_usdstringnoDecimal as string.
dailyarray of objectno
daily[].datestring (date)no
daily[].messagesintegerno
daily[].segmentsstringno
daily[].segment_rate_usdstringno
daily[].amount_usdstringno
json
{
  "from": "2026-06-05T00:00:00Z",
  "to": "2026-07-05T00:00:00Z",
  "campaign_id": null,
  "segment_rates_usd": {
    "us": "0.0100",
    "mx": "0.0300",
    "eu": "0.0500"
  },
  "messages": 2,
  "segments": "3",
  "amount_usd": "0.03",
  "daily": [
    {
      "date": "2026-07-01",
      "messages": 2,
      "segments": "3",
      "segment_rate_usd": "0.0100",
      "amount_usd": "0.03"
    }
  ]
}

400 — BadRequest ​

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

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

401 — Unauthorized ​

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

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

429 — ApiKeyRateLimited ​

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

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

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