Skip to content

Billing ​

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

Invoiceable line items.

Endpoints ​

MethodPathSummary
GET/v1/billing/accountBalance, the cap in force, spend against it and the enforcement mode
GET/v1/billing/tierThe tier in force and the pending tier change, if any
POST/v1/billing/tier/upgradeSelf-service tier UPGRADE — entitlements now, price at the next period close
GET/v1/billing/tiersThe tiers on sale, with their catalog price — what an upgrade would cost BEFORE asking for it
GET/v1/billing/alertsSpend-alert threshold crossings recorded in the current billing epoch
GET/v1/billing/summaryInvoiceable line items over a caller-chosen window (NOT the invoice)
GET/v1/billing/costGross ledger cost by campaign, carrier or agent over a bounded window (NOT the invoice)
GET/v1/billing/ledgerThe tenant's wallet ledger, entry by entry, newest first
GET/v1/billing/invoiceThe invoice for one calendar month, with the period set by the SERVER

Balance, the cap in force, spend against it and the enforcement mode ​

http
GET /v1/billing/account

The "am I about to stop dialling" read, and the counterpart of an enforcement that is unconditional since ADR #39: a tenant that runs out of balance stops dialling, and this is the tenant-facing surface that can say so first.

Every number here is a READ of something that already exists, never a second source. Money comes from the wallet the spend guard nets its holds into — open holds count as spent, so available_usd is conservative-early. cap comes from the same arithmetic the spend-alert writer fires from, and used_pct is floored, so this field never announces a threshold that has not actually been crossed.

enforcement_mode and alert_thresholds are read-only here. They are written from the admin runbook surface (PUT /v1/admin/tenants/{id}/plan/enforcement-mode and .../plan/alerts): the customer tunes WHEN it is warned, never WHETHER it is blocked.

A tenant with billing off answers 200 with billing_enabled: false and null money — not 404, which would make "billing is not on for you" indistinguishable from "your token points at nothing". cap is additionally null whenever the lane is inert: plan not effectively active, no wallet, no funding entry, or (subscription) no grant yet.

Responses

200 ​

The account read. balance and cap are independently nullable.

FieldTypeRequiredDescription
tenant_idstring (uuid)yes
currency"USD"yes
billing_enabledbooleanyesFalse when the tenant has no plan row at all.
schemeprepaid · postpaid · subscription · nullno
plan_statusstring,nullno
enforcement_modegraduated · hard_block · nullnograduated notifies the tenant on a threshold crossing; hard_block records the crossing silently. Neither decides WHETHER the block happens — that is unconditional (ADR #39). Read-only on this route.
alert_thresholdsarray of integeryesPercentage points the tenant is warned at. Empty when billing is off.
balanceobject,nullyes
balance.cash_usdstringyesDecimal as string.
balance.grant_usdstringyesDecimal as string.
balance.deposit_usdstringyesDecimal as string.
balance.credit_limit_usdstringyesDecimal as string.
balance.available_usdstringyesDecimal as string. cash + grant + credit_limit − Σ(open holds): spendable right now.
capobject,nullyes
cap.schemeprepaid · postpaid · subscriptionyes
cap.period_keystringyesThe epoch the thresholds are armed against. It changes when the tenant is funded (prepaid/postpaid) or at a new subscription period, which is what re-arms the warnings with zero deletes.
cap.limit_usdstringyesDecimal as string.
cap.spent_usdstringyesDecimal as string.
cap.used_pctstring,nullyesDecimal as string. spent / limit * 100, floored to one decimal — never rounded up, so this number stays on the same side of every threshold as the alert emitter. Null when the limit is not positive — that is "there is no cap to be used", not "100% used". Served rather than left to the client because the alert threshold is itself a percentage, and two roundings of the same ratio are how a banner ends up claiming 80% next to a bar that has not reached it.
json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "currency": "USD",
  "billing_enabled": true,
  "scheme": "prepaid",
  "plan_status": "active",
  "enforcement_mode": "graduated",
  "alert_thresholds": [
    80,
    90,
    100
  ],
  "balance": {
    "cash_usd": "412.5000",
    "grant_usd": "0.0000",
    "deposit_usd": "0.0000",
    "credit_limit_usd": "0.0000",
    "available_usd": "387.5000"
  },
  "cap": {
    "scheme": "prepaid",
    "period_key": "wallet:9f1c2f7e-77aa-4a1e-9a2b-6c0d5e4f3a21",
    "limit_usd": "1000.0000",
    "spent_usd": "612.5000",
    "used_pct": "61.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"
  }
}

The tier in force and the pending tier change, if any ​

http
GET /v1/billing/tier

The read that makes the right-vs-price gap VISIBLE. A customer who presses "upgrade plan", sees the new tier's capabilities and does NOT see the invoice move will conclude the upgrade failed — this endpoint is what lets the UI say "you asked for scale; it starts billing on 2026-10-01; until then you are on growth at $69".

tier is what the tenant has RIGHT NOW: product entitlements read the live tier, so this is the one that governs what the account can do.

pending_change is null when there is nothing pending, and that is a real answer rather than a hole — a 404 would make "you have not asked for anything" indistinguishable from "your token points at nothing" (same criterion as /v1/billing/account). It is DERIVED from the open period's frozen tier and the live one, never from a separate record that could drift out of step with either.

Both prices are strings: a numeric serialised as a JSON float loses cents, and this is the figure the dashboard shows next to "what you pay this month".

Responses

200 ​

The tier in force, plus the pending change or null.

FieldTypeRequiredDescription
tenant_idstring (uuid)yes
tierstringyesThe tier in force RIGHT NOW — the one product entitlements read.
pending_changeTierPendingChange | nullyesNull when nothing is pending. That is an answer, not a hole.
json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "tier": "scale",
  "pending_change": {
    "requested_tier": "scale",
    "requested_price_usd": "119.0000",
    "billing_tier": "growth",
    "billing_price_usd": "69.0000",
    "period_start": "2026-09-01",
    "period_end": "2026-09-30",
    "effective_from": "2026-10-01"
  }
}

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

Self-service tier UPGRADE — entitlements now, price at the next period close ​

http
POST /v1/billing/tier/upgrade

Moves the tenant UP a tier with its own bearer. Two things happen, and they happen at different times on purpose:

  • entitlements are immediate — the live tier moves, so the account can use the new tier's capabilities from this minute;
  • the price waits for the next period close — the open period was already frozen at the tier it STARTED on, so this month's invoice does not move, not even by a cent.

That split is the founder's decision (macro-tasks#158, 2026-09-07): a plan change binds "when the month of the current plan has already closed", and intra-month excess is billed through the overage lines that already exist rather than through a mid-month re-price. There is therefore no proration charge and no new invoice line — see ADR #130.

Upgrades ONLY. The destination must be in the assignable vocabulary, be on sale in the catalog, and cost strictly MORE than the tier in force. A downgrade takes effect at the next period boundary with no refund and is not served here: it answers 422 not_an_upgrade, and so does a repeated call (the tenant is already on the destination).

The route takes NO tenant identifier — it operates on the bearer's tenant and only on it, so this surface cannot move another tenant's tier.

Who may call it (core#1098, ADR #143). The tenant's own dd_ API key, or a dashboard login whose user is account_admin — the same rule as GET /v1/billing/tiers. A member login is 403 role_forbidden and nothing is written; the role is checked BEFORE the body, so a member gets that 403 whatever it sends (never the 400 / 422 below). A widget token (ddw_) is 403 forbidden.

Request body (JSON, required)

FieldTypeRequiredDescription
tierstringyesThe destination tier. Must be on sale and cost strictly more than the current one.

Responses

200 ​

The tier moved. pending_change carries what was asked for, what the open period keeps billing at, and the date the new price starts.

FieldTypeRequiredDescription
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.account_idstring (uuid)noThe EMPRESA this tenant (sede) belongs to (DA-2).
tenant.billing_account_idstring (uuid)noThe billing account the sede is invoiced under (DA-3). Shared by every sede of a consolidated empresa; one per sede when per_site.
tenant.statusactive · suspendedno
tenant.syntheticbooleannotrue for a demonstration tenant. It originates no traffic: manual dial, SMS send, the external leg of a transfer and the sandbox answer 403 synthetic_tenant. Always present, never null, so a console can warn BEFORE the action fails (core#2512).
tenant.retention_monthsintegerno
tenant.seatsintegerno
tenant.tierstarter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shieldno
tenant.voice_ai_enabledbooleanno
tenant.ai_monthly_budget_usdnumber | nullnoCost-integrity ceiling (admin-set; null = no cap).
tenant.ai_max_call_secondsinteger | nullnoPer-AI-call hard duration ceiling (seconds) set by the admin. null means the PLATFORM default applies — never "no ceiling" (core#922, ADR #115).
tenant.ai_max_call_seconds_effectiveintegernoThe ceiling FreeSWITCH is actually handed for this tenant's AI legs: ai_max_call_seconds when set, else the cell's platform default (AI_MAX_CALL_SECONDS_DEFAULT, compiled fallback 900). Always present, never null. Read-only (derived).
tenant.sms_monthly_budget_usdnumber | nullnoMonth-to-date SMS spend cap.
tenant.human_initiated_enabledbooleannoD17 human-initiated consent posture (admin-set; default false). When true, a LIVE preview/manual/CTI voice dial with no artificial and no AI voice does not require prior express consent (no ATDS). Every other gate — DNC, quiet hours, Reg F, state matrix, artificial voice — still applies unchanged.
tenant.max_cpsinteger | nullnoPer-tenant CPS ceiling (admin-set; null = no ceiling). ADR #12.
tenant.max_concurrent_channelsinteger | nullnoPer-tenant ceiling of concurrent calls, one unit per call with or without an agent (admin-set; null = no ceiling). ADR #12, ADR #185.
tenant.abandon_seller_namestring | nullnoSeller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14.
tenant.abandon_seller_phonestring | nullnoE.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14.
tenant.tax_countrystring | nullnoISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_regionstring | nullnoState/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_postal_codestring | nullnoPostal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.kyc_verified_atstring (date-time) | nullnoWhen the platform verified this customer (admin-set; null = not yet). A 1-seat account that is not verified cannot create or start a campaign that runs without an agent (403 verification_required, ADR #279).
tenant.created_atstring (date-time)no
tenant.updated_atstring (date-time)no
tierstringyesThe tier now in force — the one just asked for.
pending_changeTierPendingChange | nullyesNull in one real case: the open period was ALREADY frozen at the destination (an out-and-back within the same month), so the month already bills at what the tenant now has.
json
{
  "tenant": {
    "id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
    "name": "Acme Collections",
    "status": "active",
    "tier": "scale"
  },
  "tier": "scale",
  "pending_change": {
    "requested_tier": "scale",
    "requested_price_usd": "119.0000",
    "billing_tier": "growth",
    "billing_price_usd": "69.0000",
    "period_start": "2026-09-01",
    "period_end": "2026-09-30",
    "effective_from": "2026-10-01"
  }
}

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 ​

role_forbidden — a dashboard login (ddu_) whose user is not account_admin; the tier does not move. forbidden — a widget token (ddw_), or a suspended tenant.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "a dashboard login needs the account_admin role to change the tier"
  }
}

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 ​

not_an_upgrade (a downgrade, a lateral move at the same price, or a repeated call), unknown_tier (not in the assignable vocabulary), tier_not_sellable (in the catalog but retired / unpriced), or tier_not_in_catalog (the tier currently in force cannot be priced, so there is nothing to compare against — fail-closed).

json
{
  "error": {
    "code": "not_an_upgrade",
    "message": "tier must cost strictly more than the current one; downgrades take effect at the next period and are not served here"
  }
}

429 — ApiKeyRateLimited ​

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

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

The tiers on sale, with their catalog price — what an upgrade would cost BEFORE asking for it ​

http
GET /v1/billing/tiers

The catalog a tenant can buy from, read BEFORE POST /v1/billing/tier/upgrade: every tier on sale with its catalog price, its currency and its entitlements, which one is the tenant's and which ones are an upgrade from it. It exists so that the confirmation in front of an upgrade shows a price served by core — the same catalog the invoice is priced from — instead of one written into a client.

What is on sale. For each tier key, the NEWEST catalog version, kept only when that version is sellable AND priced (a retired tier is dropped even though older versions exist immutably), and only when the key is in the vocabulary a tenant may be moved to. Sorted by price, ascending. No tier that is not on sale is ever listed.

upgrade is the upgrade rule itself, evaluated without writing: the tier is on sale, assignable, and costs strictly more than current_price_usd. A tier flagged upgrade: true is one the POST accepts; false is one it answers with 422. When the tier in force has no catalog entry current_price_usd is null and nothing is an upgrade (the POST answers 422 tier_not_in_catalog).

Whose price. Tier prices are platform-wide: there is no per-tenant tier price in the catalog, so no other tenant's price can appear here. A design-partner discount (ADR #111) applies to the voice-AI add-on line, never to the tier price, so the price listed for a destination tier is the one this tenant starts paying for it at the next period. What the OPEN period bills at is GET /v1/billing/tier: it can differ from the list price of the tier in force, because the open period is frozen at the price it started on.

Who may read it. The tenant's own dd_ API key, or a dashboard login whose user is account_admin. A member login is 403 role_forbidden; a widget token (ddw_) is 403 forbidden. The route takes no tenant identifier.

The price is per seat, per month — the unit the seats line of the invoice multiplies by the billable seats. Money is a string so cents survive JSON.

Responses

200 ​

The tier in force, its catalog price, and the tiers on sale.

FieldTypeRequiredDescription
tenant_idstring (uuid)yes
tierstringyesThe tier in force RIGHT NOW. It is listed in tiers only if it is still on sale.
current_price_usdstring,nullyesCatalog price of the tier in force — the price an upgrade must strictly exceed. Not necessarily what the open period bills at (see GET /v1/billing/tier). Null when the tier in force has no catalog entry; then no tier is an upgrade.
tiersarray of BillingTierOfferyesEvery tier on sale that the tenant may be moved to, cheapest first.
tiers[].tierstringyesThe tier key the upgrade body names.
tiers[].labelstringyes
tiers[].versionintegeryesThe catalog version on sale (the catalog is append-only; a price change is a new version).
tiers[].monthly_price_usdstringyesCatalog price of the version on sale, PER SEAT and per month (the invoice's seats line multiplies it by the billable seats). String, so cents survive JSON.
tiers[].currencyUSDyes
tiers[].effective_fromstring (date)yesThe date the catalog version on sale took effect.
tiers[].entitlementsobjectyesThe capacity the tier grants, as the catalog stores it. On max_seats and max_concurrent_channels null means UNLIMITED; on included_ai_minutes null means the voice-AI add-on is not offered.
tiers[].entitlements.max_seatsinteger,nullyesNull means unlimited.
tiers[].entitlements.max_concurrent_channelsinteger,nullyesNull means unlimited.
tiers[].entitlements.included_minutesstring,nullyes
tiers[].entitlements.included_sms_segmentsinteger,nullyes
tiers[].entitlements.included_ai_minutesstring,nullyesNull means the voice-AI add-on is not offered on this tier; a number (zero included) means it is.
tiers[].entitlements.included_didsinteger,nullyes
tiers[].currentbooleanyesThis is the tier in force.
tiers[].upgradebooleanyesPOST /v1/billing/tier/upgrade with this tier would be accepted: on sale and strictly more expensive than current_price_usd.
pay_as_you_goBillingPayAsYouGo | nullnoThe pay-as-you-go entry mode: ONE seat and ALL the consumption drawn from the prepaid balance, with no allowance pool. It is the prepaid scheme on the starter tier, so seat_price_usd is the catalog price of that entry per seat and month (read from the row, never restated) and seats is its seat floor. Null when the entry tier is not on sale.
json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "tier": "growth",
  "current_price_usd": "69.0000",
  "tiers": [
    {
      "tier": "starter",
      "label": "Starter",
      "version": 1,
      "monthly_price_usd": "25.0000",
      "currency": "USD",
      "effective_from": "2026-07-07",
      "entitlements": {
        "max_seats": 5,
        "max_concurrent_channels": null,
        "included_minutes": "500",
        "included_sms_segments": 200,
        "included_ai_minutes": null,
        "included_dids": 1
      },
      "current": false,
      "upgrade": false
    },
    {
      "tier": "growth",
      "label": "Growth",
      "version": 1,
      "monthly_price_usd": "69.0000",
      "currency": "USD",
      "effective_from": "2026-07-07",
      "entitlements": {
        "max_seats": 25,
        "max_concurrent_channels": null,
        "included_minutes": "1500",
        "included_sms_segments": 500,
        "included_ai_minutes": "100",
        "included_dids": 2
      },
      "current": true,
      "upgrade": false
    },
    {
      "tier": "scale",
      "label": "Scale",
      "version": 1,
      "monthly_price_usd": "119.0000",
      "currency": "USD",
      "effective_from": "2026-07-07",
      "entitlements": {
        "max_seats": 100,
        "max_concurrent_channels": null,
        "included_minutes": "2500",
        "included_sms_segments": 1500,
        "included_ai_minutes": "150",
        "included_dids": 3
      },
      "current": false,
      "upgrade": true
    },
    {
      "tier": "enterprise",
      "label": "Enterprise",
      "version": 1,
      "monthly_price_usd": "199.0000",
      "currency": "USD",
      "effective_from": "2026-07-07",
      "entitlements": {
        "max_seats": null,
        "max_concurrent_channels": null,
        "included_minutes": "4000",
        "included_sms_segments": 2000,
        "included_ai_minutes": "300",
        "included_dids": 5
      },
      "current": false,
      "upgrade": true
    }
  ]
}

401 — Unauthorized ​

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

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

403 — Forbidden ​

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

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

404 — NotFound ​

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

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

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

Spend-alert threshold crossings recorded in the current billing epoch ​

http
GET /v1/billing/alerts

The history behind the percentage that GET /v1/billing/account reports live: which thresholds have actually fired for this tenant in the epoch the alerts are armed against.

Every row is served as it was recorded, never recomputed. spent_usd, limit_usd and enforcement_mode are the snapshot AT the crossing, which is the reason the underlying ledger is append-only: an 80% warning stays an 80% warning even if the tenant topped up afterwards. Re-deriving these from today's balance would answer "what would this alert look like if it fired now" — a different question, and one that would let this list contradict the notification the customer already received.

period_key is the SAME epoch GET /v1/billing/account reports under cap.period_key, so the two surfaces can never be describing different periods. It changes when the tenant is funded or at a new subscription period, which re-arms every threshold with zero deletes — so an empty list right after a top-up means "nothing crossed YET in the new epoch", not "nothing ever crossed".

A tenant with no active plan, or whose lane has no epoch yet, answers 200 with period_key: null and an empty list — never 404, for the same reason as /billing/account: a 404 would make "you have crossed nothing" indistinguishable from "your token is pointing at nothing".

Responses

200 ​

The crossings of the current epoch, newest first. Empty when the lane is inert or nothing has crossed yet.

FieldTypeRequiredDescription
tenant_idstring (uuid)yes
period_keystring,nullyesThe epoch the crossings belong to, identical to cap.period_key of GET /v1/billing/account. Null when the lane has no epoch (no active plan, no wallet, no funding entry, or (subscription) no grant yet) — in which case alerts is empty.
alertsarray of objectyes
alerts[].threshold_pctintegeryesThe percentage that fired, as configured in the plan's alert_thresholds when the epoch was armed.
alerts[].schemesubscription · prepaid · postpaidyes
alerts[].enforcement_modegraduated · hard_blockyesThe mode in force AT the crossing. It can differ from the tenant's mode today: that is the point of a snapshot.
alerts[].spent_usdstringyesDecimal as string. Spend AT the crossing, not today's.
alerts[].limit_usdstringyesDecimal as string. The cap AT the crossing, not today's.
alerts[].crossed_atstring (date-time)yesWhen the crossing was recorded (UTC, microseconds).
json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "period_key": "wallet:9f1c2f7e-77aa-4a1e-9a2b-6c0d5e4f3a21",
  "alerts": [
    {
      "threshold_pct": 90,
      "scheme": "prepaid",
      "enforcement_mode": "graduated",
      "spent_usd": "902.0000",
      "limit_usd": "1000.0000",
      "crossed_at": "2026-08-30T18:22:04.518423Z"
    },
    {
      "threshold_pct": 80,
      "scheme": "prepaid",
      "enforcement_mode": "graduated",
      "spent_usd": "812.5000",
      "limit_usd": "1000.0000",
      "crossed_at": "2026-08-29T11:04:51.201884Z"
    }
  ]
}

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

Invoiceable line items over a caller-chosen window (NOT the invoice) ​

http
GET /v1/billing/summary

The exploratory read model: the caller picks [from, to), so this is what the line items look like over an arbitrary window — useful to inspect, and NOT the bill. The invoice is GET /v1/billing/invoice, whose period the SERVER sets and the client cannot move.

Seats by tier, DID overage ($25 / 10-DID pack) and voice-AI metered usage (max(metered, $200) while the add-on is enabled, $0 when off) priced from the plan catalog in code (Dialer.Billing.Plan). The seat billing floor is PER PLAN and a live seats line publishes the one it applies as seat_minimum: 1 on starter and growth, 3 on scale, 25 on enterprise (so an enterprise tenant with 10 occupied seats bills billable_seats: 25). A ledger_snapshot seats line (a closed month) carries seat_minimum: null, like occupied_seats: null: the close stamps the quantity, not the floor it applied, and months closed before the floor became per-plan applied 3 on every plan. Defaults: trailing 30 UTC days. The [from, to) window is half-open (to EXCLUSIVE); day-ledger lines (house/inbound minutes, DID rent, SMS) derive their closed UTC-day range from it, so a to at exact UTC midnight is a billing cut and that day belongs to the NEXT period's view only. An intra-day window therefore sums WHOLE UTC days on those lines, and the response says so: period.day_ledger carries the closed day range they summed next to the from/to that were asked for (core#1383; the CSV carries it as day_ledger_from_day/day_ledger_to_day/ day_ledger_kinds metadata rows). ?format=csv / Accept: text/csv for the invoicing artifact (with 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.
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

The invoiceable line items (JSON) or the CSV artifact.

FieldTypeRequiredDescription
tenant_idstring (uuid)no
tierstarter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shieldno
tier_labelstringno
voice_ai_enabledbooleanno
currency"USD"no
periodobjectno
period.fromstring (date-time)noThe from that was asked for (inclusive).
period.tostring (date-time)noThe to that was asked for (EXCLUSIVE).
period.day_ledgerobjectnoWhat the day-ledger lines actually summed (core#1383). Those lines read billable_events, which is keyed by UTC day, so a window that does not start and end at UTC midnight sums its WHOLE days: from=16:49Z&to=17:00Z reads that entire day. This is the closed range they read, derived from [from, to) (a to at exact UTC midnight is a cut, so its day is not in it). It applies to kinds only: voice_ai meters the exact [from, to), and the monthly lines resolve as of its end.
period.day_ledger.from_daystring (date)yesFirst UTC day summed (inclusive).
period.day_ledger.to_daystring (date)yesLast UTC day summed (inclusive).
period.day_ledger.kindsarray of house_minutes · inbound_minutes · did_rent · smsyesThe line kinds read over this day range.
line_itemsarray of objectno
line_items[].kindseats · voice_ai · house_minutes · inbound_minutes · did_rent · did_overage · did_overage_customer · sms · wav_surchargeyes
line_items[].cadencemonthly · meteredyes
line_items[].amount_usdnumberyes
line_items[].settled_from_balance_usdnumbernoPresent on the four METERED lines (house_minutes, inbound_minutes, sms, voice_ai) only: what the tenant's balance has been debited for this line's meter over its days (core#2199, ADR #276). It is the ledger's fact so far, not a second price: amount_usd is the sale and does not change. The frozen invoice brings it to amount_usd at the close.
line_items[].fiscal_bucketsaas · telecom · pass_through · taxnoHow the line is classified fiscally (ADR #73): saas is software revenue, telecom is telecommunications revenue, pass_through is a regulatory surcharge the company remits on its own obligation, and tax is money collected on behalf of a jurisdiction. Published since MT-D of macro-tasks#448, when the founder confirmed the framing; a document frozen before that may carry no classification, and is not re-classified.
line_items[].allowance_poolminutes · sms_segments · ai_minutesnoPresent on the four METERED lines only (house_minutes, inbound_minutes, sms, voice_ai). Names the plan_tiers catalog COLUMN this line's allowance came out of, which is how a reader can tell that house_minutes and inbound_minutes are SHARING one included_minutes between them rather than carrying two (core#219, ADR #110).
line_items[].included_allowancenumbernoUnits of this meter the tier already includes for the period, as they apply to THIS line — for the two minute lines it is the share of the pooled included_minutes proportional to what each one consumed, and the two shares add up to the column. 0 means the tier includes nothing on this axis, which is how a NULL catalog column reads — never unlimited — and also what every non-subscription plan scheme resolves to, since prepaid and postpaid never bought a tier version. With 0 the line is charged exactly as it was before this field existed.
line_items[].consumed_quantitynumbernoWhat the meter measured over the period, before the allowance. Same figure the line's own meter field carries (billable_minutes / segments).
line_items[].overage_quantitynumbernoThe BILLABLE units: max(consumed_quantity - included_allowance, 0). Never negative — a meter cannot produce a credit.
line_items[].gross_amount_usdnumbernoThe untouched LEDGER sum for this line, before the allowance was applied. On the three ledger lines (house_minutes, inbound_minutes, sms) amount_usd is the same proportion of it that overage_quantity is of consumed_quantity, so the two side by side are the audit of the deduction — and the evidence that no billable_events row was re-rated. On voice_ai that proportion is a FLOOR, not an equality: while the add-on is enabled amount_usd is max(deducted, monthly_minimum_usd) and can therefore exceed gross_amount_usd.
total_usdnumberno
balance_settled_usdnumbernoThe sum of the lines' settled_from_balance_usd (core#2199, ADR #276).
amount_due_usdnumbernototal_usd - balance_settled_usd, taxes included. total_usd is unchanged: the invoice stays the sale.
json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "tier": "scale",
  "tier_label": "Scale",
  "voice_ai_enabled": true,
  "currency": "USD",
  "period": {
    "from": "2026-06-05T00:00:00Z",
    "to": "2026-07-05T00:00:00Z",
    "day_ledger": {
      "from_day": "2026-06-05",
      "to_day": "2026-07-04",
      "kinds": [
        "house_minutes",
        "inbound_minutes",
        "did_rent",
        "sms"
      ]
    }
  },
  "line_items": [
    {
      "kind": "seats",
      "cadence": "monthly",
      "source": "live",
      "occupied_seats": 5,
      "billable_seats": 5,
      "seat_minimum": 3,
      "unit_price_usd": 119,
      "amount_usd": 595
    },
    {
      "kind": "did_overage",
      "cadence": "monthly",
      "source": "live",
      "monitored_dids": 8,
      "included_dids": 3,
      "overage_dids": 1,
      "pack_size": 10,
      "pack_price_usd": 25,
      "amount_usd": 25
    },
    {
      "kind": "did_overage_customer",
      "cadence": "monthly",
      "source": "live",
      "monitored_dids": 8,
      "included_dids": 3,
      "overage_dids": 4,
      "pack_size": 10,
      "pack_price_usd": 25,
      "amount_usd": 0
    },
    {
      "kind": "wav_surcharge",
      "cadence": "monthly",
      "source": "live",
      "recording_format": "wav",
      "billable_seats": 5,
      "unit_price_usd": 5,
      "amount_usd": 25
    },
    {
      "kind": "voice_ai",
      "cadence": "metered",
      "enabled": true,
      "billable_minutes": 900,
      "minute_rate_usd": 0.25,
      "metered_usd": 225,
      "monthly_minimum_usd": 200,
      "minimum_applied": false,
      "amount_usd": 225
    }
  ],
  "total_usd": 870
}

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

Gross ledger cost by campaign, carrier or agent over a bounded window (NOT the invoice) ​

http
GET /v1/billing/cost

"Which campaign / carrier / agent is spending right now": the usage LEDGER (billable_events) summed over [from, to) and grouped by ONE dimension per request (ADR #218, core#1647). The days already closed are read from the daily rollup (ADR #232, core#1751); only the two most recent UTC days (today and yesterday) are summed directly from the ledger joined to the CDR. sources says how many days of the window came from each place.

Behind a flag. The route answers only for tenants with billing_cost_enabled ON, which is OFF by default: with it OFF every caller, of any role, gets the same 404 not_found, as if the route did not exist. Nothing is deleted or hidden when it is OFF.

It is the gross ledger sum, not the bill. The included allowance, the volume tier, the design-partner discount and the monthly minimum are applied on the invoice and are NOT split across groups; the reconciliation block puts gross_total next to the lines of GET /v1/billing/summary over the same window and lists what is not allocated (not_allocated). Whoever needs "what was billed" reads the invoice.

Nothing is dropped. Every ledger row lands in exactly one group: the resolved campaign / carrier / agent, or the bucket with key: null and unattributed: true (no campaign, a CDR without a carrier, an agent not assigned, a call with no CDR row, an SMS). The groups of each currency add up to gross_total; if they ever did not, the answer is 500 cost_unreconciled, never a partial number. Currencies are never mixed or converted: each group and each total carries its own. billed_by says who bills the telecom MINUTES of those calls: platform (house trunk), tenant_carrier (the customer's own carrier; the ledger amount is kept whole) or null.

Window. from and to are MANDATORY RFC 3339 instants, from < to, at most 31 days apart (400 otherwise), half-open like /v1/billing/summary: a to at exact UTC midnight belongs to the next window. The ledger is keyed by UTC day, so an intra-day window sums its WHOLE UTC days; ledger_days says which.

Who. A dd_ API key or a dashboard login with the account_admin role. Anyone else gets the same 403 role_forbidden whatever they send and whatever the tenant holds: a refusal never says whether there is any cost to read. Tenant isolation is the database's (RLS): another tenant's rows are never read.

When it cannot answer. If part of the window is neither closed in the rollup nor one of the two most recent days, or a closed day's rollup does not square with the ledger, the answer is 503 unavailable: a partial number is never published. Retry later.

Parameters

NameInTypeRequiredDescription
group_byquerycampaign · carrier · agentyesThe ONE dimension to group by.
fromquerystring (date-time)yesRFC 3339 lower bound, inclusive.
toquerystring (date-time)yesRFC 3339 upper bound, EXCLUSIVE; at most 31 days after from.

Responses

200 ​

The groups, the gross total and the reconciliation block.

FieldTypeRequiredDescription
group_bycampaign · carrier · agentyes
fromstring (date-time)yes
tostring (date-time)yes
ledger_daysobjectyesThe closed UTC-day range the ledger was read over.
ledger_days.fromstring (date)yes
ledger_days.tostring (date)yes
sourcesobjectyesHow many UTC days of ledger_days were read from the daily rollup (closed days), summed directly from the ledger (the two most recent) or were empty (no events).
sources.rollupintegeryes
sources.directintegeryes
sources.emptyintegeryes
groupsarray of BillingCostGroupyes
groups[].keystring,nullyesThe campaign / carrier / agent id; null in the unattributed bucket.
groups[].unattributedbooleanyes
groups[].currencystringyes
groups[].billed_byplatform · tenant_carrier · nullyesWho bills the telecom minutes of those calls.
groups[].amountstringyes
groups[].eventsintegeryes
gross_totalarray of BillingCostTotalyesThe ledger sum per currency.
gross_total[].currencystringyes
gross_total[].amountstringyes
gross_total[].eventsintegeryes
reconciliationobjectyes
reconciliation.basisledger_grossyes
reconciliation.groups_sumarray of BillingCostTotalyesThe sum of groups per currency; equal to gross_total on every 200.
reconciliation.groups_sum[].currencystringyes
reconciliation.groups_sum[].amountstringyes
reconciliation.groups_sum[].eventsintegeryes
reconciliation.invoiceobjectyesThe lines of GET /v1/billing/summary over the same window, or status: unavailable with the reason when the window cannot be priced (the cost itself does not depend on it).
reconciliation.invoice.statusavailable · unavailableyes
reconciliation.invoice.reasonstringnoOnly when unavailable.
reconciliation.invoice.currencystringno
reconciliation.invoice.total_usdstringno
reconciliation.invoice.linesarray of objectno
reconciliation.invoice.lines[].kindstringyes
reconciliation.invoice.lines[].amount_usdstringyes
reconciliation.invoice.lines[].gross_amount_usdstring,nullyesThe untouched ledger sum, on the lines that carry an allowance; null otherwise.
reconciliation.not_allocatedarray of stringyesWhat the invoice applies on top of the gross and this resource does NOT split across groups.
reconciliation.notestringyes
json
{
  "group_by": "campaign",
  "from": "2026-10-01T00:00:00Z",
  "to": "2026-10-08T00:00:00Z",
  "ledger_days": {
    "from": "2026-10-01",
    "to": "2026-10-07"
  },
  "sources": {
    "rollup": 5,
    "direct": 2,
    "empty": 0
  },
  "groups": [
    {
      "key": "6f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d",
      "unattributed": false,
      "currency": "USD",
      "billed_by": "platform",
      "amount": "41.2500",
      "events": 310
    },
    {
      "key": null,
      "unattributed": true,
      "currency": "USD",
      "billed_by": null,
      "amount": "8.7500",
      "events": 52
    }
  ],
  "gross_total": [
    {
      "currency": "USD",
      "amount": "50.0000",
      "events": 362
    }
  ],
  "reconciliation": {
    "basis": "ledger_gross",
    "groups_sum": [
      {
        "currency": "USD",
        "amount": "50.0000",
        "events": 362
      }
    ],
    "invoice": {
      "status": "available",
      "currency": "USD",
      "total_usd": "870.0",
      "lines": [
        {
          "kind": "seats",
          "amount_usd": "595",
          "gross_amount_usd": null
        },
        {
          "kind": "house_minutes",
          "amount_usd": "0",
          "gross_amount_usd": "41.2500"
        }
      ]
    },
    "not_allocated": [
      "included_allowance",
      "volume_tier",
      "discount",
      "monthly_minimum"
    ],
    "note": "gross_total is the sum of the usage ledger, not the amount billed; the included allowance, volume tier, discount and monthly minimum are applied on the invoice and are not split across groups"
  }
}

400 ​

group_by missing or not one of campaign, carrier, agent; from or to missing or not RFC 3339; from not before to; or a window over 31 days.

json
{
  "error": {
    "code": "bad_request",
    "message": "date range must be <= 31 days"
  }
}

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 ​

role_forbidden — a dashboard login without the account_admin role. The same body for every request of that login, so it reveals nothing about the tenant's cost. A widget or other non-tenant scope gets forbidden.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "a dashboard login needs the account_admin role to read billing cost"
  }
}

404 ​

not_found — the tenant has billing_cost_enabled OFF (the default). The same for every role and every parameter: the flag answers before anything else.

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

500 ​

cost_unreconciled — the groups did not add up to the ledger total; nothing is published.

503 ​

unavailable — part of the window is not closed in the rollup (and is not one of the two most recent days) or a closed day does not reconcile; nothing is published.

json
{
  "error": {
    "code": "unavailable",
    "message": "the cost of part of this window is not closed yet; nothing is published, retry later"
  }
}

The tenant's wallet ledger, entry by entry, newest first ​

http
GET /v1/billing/ledger

"Why did my balance drop": the rows of the tenant's wallet ledger (balance_entries), newest first and keyset-paginated (core#2573). Every movement says its entry_type, the signed per-bucket deltas, the UTC day it belongs to (occurred_on) and the two keys that join it to its origin: ref_uuid (the billable event id for a debit, the payment provider's idempotency key for a topup, YYYY-MM for a period grant) and billable_event_id. The row carries NO free text and no personal data.

Behind a flag. The route answers only for tenants with billing_ledger_enabled ON, which is OFF by default: with it OFF every caller, of any role, gets the same 404 not_found, as if the route did not exist. Nothing is deleted or hidden when it is OFF.

Pagination. limit (default 50, at most 200) and cursor (the next_cursor of the previous page; null on the last page). Rows come ordered by recorded_at then id, both descending, so a page never repeats or skips a row that already existed when the first page was read. from / to bound recorded_at ([from, to), both optional); entry_type keeps one kind of movement.

Who. A dd_ API key or a dashboard login with the account_admin role. Anyone else gets the same 403 role_forbidden whatever they send. Tenant isolation is the database's (RLS): another tenant's rows are never read.

Parameters

NameInTypeRequiredDescription
fromquerystring (date-time)noRFC 3339 lower bound of recorded_at, inclusive.
toquerystring (date-time)noRFC 3339 upper bound of recorded_at, EXCLUSIVE; must be after from.
entry_typequerytopup · debit · refund · adjustment · subscription_grant · grant_expiry · deposit_hold · deposit_release · deposit_applynoKeep one kind of movement.
limitqueryintegernoPage size, default 50, capped at 200.
cursorquerystringnoThe next_cursor of the previous page.

Responses

200 ​

A page of ledger entries.

FieldTypeRequiredDescription
entriesarray of BillingLedgerEntryyes
entries[].idstring (uuid)yes
entries[].entry_typetopup · debit · refund · adjustment · subscription_grant · grant_expiry · deposit_hold · deposit_release · deposit_applyyes
entries[].event_typestring,nullyesThe billable event type for a debit; null for the other movements.
entries[].ref_uuidstringyesThe billable event id for a debit, the payment idempotency key for a topup, YYYY-MM for a period grant.
entries[].billable_event_idstring,null (uuid)yesThe usage event that caused a debit; null for the other movements.
entries[].cash_deltastringyesSigned decimal, as a string.
entries[].grant_deltastringyesSigned decimal, as a string.
entries[].deposit_deltastringyesSigned decimal, as a string.
entries[].occurred_onstring (date)yesThe UTC day the movement belongs to.
entries[].recorded_atstring (date-time)yesWhen the entry landed.
next_cursorstring,nullyesPass it as cursor for the next page; null on the last one.
json
{
  "entries": [
    {
      "id": "0b7e9f2a-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "entry_type": "debit",
      "event_type": "sms_segment",
      "ref_uuid": "3f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d",
      "billable_event_id": "3f1d2c3a-5b7e-4f0a-9c1d-2e3f4a5b6c7d",
      "cash_delta": "-0.5000",
      "grant_delta": "0.0000",
      "deposit_delta": "0.0000",
      "occurred_on": "2026-10-24",
      "recorded_at": "2026-10-24T15:04:05.123456Z"
    }
  ],
  "next_cursor": null
}

400 ​

from or to not RFC 3339, from not before to, an unknown entry_type, or an invalid limit / cursor.

json
{
  "error": {
    "code": "bad_request",
    "message": "invalid limit or cursor"
  }
}

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 ​

role_forbidden — a dashboard login without the account_admin role. The same body for every request of that login. A widget or other non-tenant scope gets forbidden.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "a dashboard login needs the account_admin role to read the billing ledger"
  }
}

404 ​

not_found — the tenant has billing_ledger_enabled OFF (the default). The same for every role and every parameter: the flag answers before anything else.

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

The invoice for one calendar month, with the period set by the SERVER ​

http
GET /v1/billing/invoice

Same line items as GET /v1/billing/summary, over a window the CLIENT CANNOT CHOOSE: the billing cycle is the calendar month — the same key the monthly close stamps its seat_month ledger row with — and the only knob is which month.

period is YYYY-MM and defaults to the month in flight. Anything that is not a real month is a 400, never a nearby month.

from and to are REJECTED here with a 400 that names them, rather than ignored: a caller that sent them believes it narrowed the bill, so silently billing a different window would be indistinguishable from honouring them. Use /v1/billing/summary for a free range.

Reading the same closed month twice returns the same document — that is the point of anchoring the period server-side.

Once the monthly close has ISSUED the month's document, this read serves THAT: the frozen invoices/invoice_lines rows — numbered, immutable, never a recompute — as the FrozenInvoice shape (source: frozen_invoice, with invoice_number). A month without an issued document (the month in flight, or a past month whose close has not converged yet) answers the live BillingSummary read-model, which has no invoice number.

?format=csv / Accept: text/csv for the invoicing artifact (with X-Artifact-SHA256). The meta block's report row discriminates the two, exactly like source does in the JSON: a frozen month is the document's rows verbatim (report,invoice, and the meta carries invoice_number and issued_at), while a month with no issued document is the read-model (report,invoice_draft, no invoice number). The filenames follow the same split: invoice_<period_key>_n<num>.csv once issued, invoice_<period_key>.csv while it is not.

Parameters

NameInTypeRequiredDescription
periodquerystringnoThe calendar month to invoice, YYYY-MM. Defaults to the current UTC month.
formatquerycsvnocsv for the downloadable artifact (equivalent: Accept: text/csv).

Responses

200 ​

The invoice line items (JSON) or the CSV artifact.

frozen

json
{
  "source": "frozen_invoice",
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "invoice_number": 7,
  "period_key": "2026-06",
  "period_from": "2026-06-01",
  "period_to": "2026-06-30",
  "currency": "USD",
  "issued_at": "2026-07-01T00:00:07.412331Z",
  "total": "595.0000",
  "balance_settled_usd": "0.0000",
  "amount_due_usd": "595.0000",
  "line_items": [
    {
      "position": 1,
      "line_type": "seats",
      "description": "5 billable seats (closed-month snapshot) x $119",
      "quantity": "5.0000",
      "unit_price": "119.0000",
      "amount": "595.0000",
      "settled_from_balance_usd": null
    }
  ]
}

live

json
{
  "tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
  "tier": "scale",
  "tier_label": "Scale",
  "voice_ai_enabled": true,
  "currency": "USD",
  "period": {
    "from": "2026-06-01T00:00:00Z",
    "to": "2026-07-01T00:00:00Z",
    "day_ledger": {
      "from_day": "2026-06-01",
      "to_day": "2026-06-30",
      "kinds": [
        "house_minutes",
        "inbound_minutes",
        "did_rent",
        "sms"
      ]
    }
  },
  "line_items": [
    {
      "kind": "seats",
      "cadence": "monthly",
      "source": "ledger_snapshot",
      "occupied_seats": null,
      "billable_seats": 5,
      "seat_minimum": null,
      "unit_price_usd": 119,
      "amount_usd": 595
    }
  ],
  "total_usd": 595
}

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.