Appearance
Billing
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Invoiceable line items.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /v1/billing/account | Balance, the cap in force, spend against it and the enforcement mode |
GET | /v1/billing/tier | The tier in force and the pending tier change, if any |
POST | /v1/billing/tier/upgrade | Self-service tier UPGRADE — entitlements now, price at the next period close |
GET | /v1/billing/tiers | The tiers on sale, with their catalog price — what an upgrade would cost BEFORE asking for it |
GET | /v1/billing/alerts | Spend-alert threshold crossings recorded in the current billing epoch |
GET | /v1/billing/summary | Invoiceable line items over a caller-chosen window (NOT the invoice) |
GET | /v1/billing/cost | Gross ledger cost by campaign, carrier or agent over a bounded window (NOT the invoice) |
GET | /v1/billing/ledger | The tenant's wallet ledger, entry by entry, newest first |
GET | /v1/billing/invoice | The 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/accountThe "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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | yes | |
currency | "USD" | yes | |
billing_enabled | boolean | yes | False when the tenant has no plan row at all. |
scheme | prepaid · postpaid · subscription · null | no | |
plan_status | string,null | no | |
enforcement_mode | graduated · hard_block · null | no | graduated 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_thresholds | array of integer | yes | Percentage points the tenant is warned at. Empty when billing is off. |
balance | object,null | yes | |
balance.cash_usd | string | yes | Decimal as string. |
balance.grant_usd | string | yes | Decimal as string. |
balance.deposit_usd | string | yes | Decimal as string. |
balance.credit_limit_usd | string | yes | Decimal as string. |
balance.available_usd | string | yes | Decimal as string. cash + grant + credit_limit − Σ(open holds): spendable right now. |
cap | object,null | yes | |
cap.scheme | prepaid · postpaid · subscription | yes | |
cap.period_key | string | yes | The 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_usd | string | yes | Decimal as string. |
cap.spent_usd | string | yes | Decimal as string. |
cap.used_pct | string,null | yes | Decimal 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/tierThe 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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | yes | |
tier | string | yes | The tier in force RIGHT NOW — the one product entitlements read. |
pending_change | TierPendingChange | null | yes | Null 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/upgradeMoves 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)
| Field | Type | Required | Description |
|---|---|---|---|
tier | string | yes | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant | object (Tenant) | yes | |
tenant.id | string (uuid) | no | |
tenant.name | string | no | |
tenant.account_id | string (uuid) | no | The EMPRESA this tenant (sede) belongs to (DA-2). |
tenant.billing_account_id | string (uuid) | no | The billing account the sede is invoiced under (DA-3). Shared by every sede of a consolidated empresa; one per sede when per_site. |
tenant.status | active · suspended | no | |
tenant.synthetic | boolean | no | true 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_months | integer | no | |
tenant.seats | integer | no | |
tenant.tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tenant.voice_ai_enabled | boolean | no | |
tenant.ai_monthly_budget_usd | number | null | no | Cost-integrity ceiling (admin-set; null = no cap). |
tenant.ai_max_call_seconds | integer | null | no | Per-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_effective | integer | no | The 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_usd | number | null | no | Month-to-date SMS spend cap. |
tenant.human_initiated_enabled | boolean | no | D17 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_cps | integer | null | no | Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR #12. |
tenant.max_concurrent_channels | integer | null | no | Per-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_name | string | null | no | Seller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14. |
tenant.abandon_seller_phone | string | null | no | E.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14. |
tenant.tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_region | string | null | no | State/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_postal_code | string | null | no | Postal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.kyc_verified_at | string (date-time) | null | no | When 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_at | string (date-time) | no | |
tenant.updated_at | string (date-time) | no | |
tier | string | yes | The tier now in force — the one just asked for. |
pending_change | TierPendingChange | null | yes | Null 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/tiersThe 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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | yes | |
tier | string | yes | The tier in force RIGHT NOW. It is listed in tiers only if it is still on sale. |
current_price_usd | string,null | yes | Catalog 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. |
tiers | array of BillingTierOffer | yes | Every tier on sale that the tenant may be moved to, cheapest first. |
tiers[].tier | string | yes | The tier key the upgrade body names. |
tiers[].label | string | yes | |
tiers[].version | integer | yes | The catalog version on sale (the catalog is append-only; a price change is a new version). |
tiers[].monthly_price_usd | string | yes | Catalog 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[].currency | USD | yes | |
tiers[].effective_from | string (date) | yes | The date the catalog version on sale took effect. |
tiers[].entitlements | object | yes | The 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_seats | integer,null | yes | Null means unlimited. |
tiers[].entitlements.max_concurrent_channels | integer,null | yes | Null means unlimited. |
tiers[].entitlements.included_minutes | string,null | yes | |
tiers[].entitlements.included_sms_segments | integer,null | yes | |
tiers[].entitlements.included_ai_minutes | string,null | yes | Null means the voice-AI add-on is not offered on this tier; a number (zero included) means it is. |
tiers[].entitlements.included_dids | integer,null | yes | |
tiers[].current | boolean | yes | This is the tier in force. |
tiers[].upgrade | boolean | yes | POST /v1/billing/tier/upgrade with this tier would be accepted: on sale and strictly more expensive than current_price_usd. |
pay_as_you_go | BillingPayAsYouGo | null | no | The 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/alertsThe 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.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | yes | |
period_key | string,null | yes | The 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. |
alerts | array of object | yes | |
alerts[].threshold_pct | integer | yes | The percentage that fired, as configured in the plan's alert_thresholds when the epoch was armed. |
alerts[].scheme | subscription · prepaid · postpaid | yes | |
alerts[].enforcement_mode | graduated · hard_block | yes | The mode in force AT the crossing. It can differ from the tenant's mode today: that is the point of a snapshot. |
alerts[].spent_usd | string | yes | Decimal as string. Spend AT the crossing, not today's. |
alerts[].limit_usd | string | yes | Decimal as string. The cap AT the crossing, not today's. |
alerts[].crossed_at | string (date-time) | yes | When 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/summaryThe 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
format | query | csv | no | csv for the downloadable artifact (equivalent: Accept: text/csv). |
Responses
200
The invoiceable line items (JSON) or the CSV artifact.
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | no | |
tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tier_label | string | no | |
voice_ai_enabled | boolean | no | |
currency | "USD" | no | |
period | object | no | |
period.from | string (date-time) | no | The from that was asked for (inclusive). |
period.to | string (date-time) | no | The to that was asked for (EXCLUSIVE). |
period.day_ledger | object | no | What 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_day | string (date) | yes | First UTC day summed (inclusive). |
period.day_ledger.to_day | string (date) | yes | Last UTC day summed (inclusive). |
period.day_ledger.kinds | array of house_minutes · inbound_minutes · did_rent · sms | yes | The line kinds read over this day range. |
line_items | array of object | no | |
line_items[].kind | seats · voice_ai · house_minutes · inbound_minutes · did_rent · did_overage · did_overage_customer · sms · wav_surcharge | yes | |
line_items[].cadence | monthly · metered | yes | |
line_items[].amount_usd | number | yes | |
line_items[].settled_from_balance_usd | number | no | Present 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_bucket | saas · telecom · pass_through · tax | no | How 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_pool | minutes · sms_segments · ai_minutes | no | Present 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_allowance | number | no | Units 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_quantity | number | no | What the meter measured over the period, before the allowance. Same figure the line's own meter field carries (billable_minutes / segments). |
line_items[].overage_quantity | number | no | The BILLABLE units: max(consumed_quantity - included_allowance, 0). Never negative — a meter cannot produce a credit. |
line_items[].gross_amount_usd | number | no | The 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_usd | number | no | |
balance_settled_usd | number | no | The sum of the lines' settled_from_balance_usd (core#2199, ADR #276). |
amount_due_usd | number | no | total_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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
group_by | query | campaign · carrier · agent | yes | The ONE dimension to group by. |
from | query | string (date-time) | yes | RFC 3339 lower bound, inclusive. |
to | query | string (date-time) | yes | RFC 3339 upper bound, EXCLUSIVE; at most 31 days after from. |
Responses
200
The groups, the gross total and the reconciliation block.
| Field | Type | Required | Description |
|---|---|---|---|
group_by | campaign · carrier · agent | yes | |
from | string (date-time) | yes | |
to | string (date-time) | yes | |
ledger_days | object | yes | The closed UTC-day range the ledger was read over. |
ledger_days.from | string (date) | yes | |
ledger_days.to | string (date) | yes | |
sources | object | yes | How 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.rollup | integer | yes | |
sources.direct | integer | yes | |
sources.empty | integer | yes | |
groups | array of BillingCostGroup | yes | |
groups[].key | string,null | yes | The campaign / carrier / agent id; null in the unattributed bucket. |
groups[].unattributed | boolean | yes | |
groups[].currency | string | yes | |
groups[].billed_by | platform · tenant_carrier · null | yes | Who bills the telecom minutes of those calls. |
groups[].amount | string | yes | |
groups[].events | integer | yes | |
gross_total | array of BillingCostTotal | yes | The ledger sum per currency. |
gross_total[].currency | string | yes | |
gross_total[].amount | string | yes | |
gross_total[].events | integer | yes | |
reconciliation | object | yes | |
reconciliation.basis | ledger_gross | yes | |
reconciliation.groups_sum | array of BillingCostTotal | yes | The sum of groups per currency; equal to gross_total on every 200. |
reconciliation.groups_sum[].currency | string | yes | |
reconciliation.groups_sum[].amount | string | yes | |
reconciliation.groups_sum[].events | integer | yes | |
reconciliation.invoice | object | yes | The 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.status | available · unavailable | yes | |
reconciliation.invoice.reason | string | no | Only when unavailable. |
reconciliation.invoice.currency | string | no | |
reconciliation.invoice.total_usd | string | no | |
reconciliation.invoice.lines | array of object | no | |
reconciliation.invoice.lines[].kind | string | yes | |
reconciliation.invoice.lines[].amount_usd | string | yes | |
reconciliation.invoice.lines[].gross_amount_usd | string,null | yes | The untouched ledger sum, on the lines that carry an allowance; null otherwise. |
reconciliation.not_allocated | array of string | yes | What the invoice applies on top of the gross and this resource does NOT split across groups. |
reconciliation.note | string | yes |
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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string (date-time) | no | RFC 3339 lower bound of recorded_at, inclusive. |
to | query | string (date-time) | no | RFC 3339 upper bound of recorded_at, EXCLUSIVE; must be after from. |
entry_type | query | topup · debit · refund · adjustment · subscription_grant · grant_expiry · deposit_hold · deposit_release · deposit_apply | no | Keep one kind of movement. |
limit | query | integer | no | Page size, default 50, capped at 200. |
cursor | query | string | no | The next_cursor of the previous page. |
Responses
200
A page of ledger entries.
| Field | Type | Required | Description |
|---|---|---|---|
entries | array of BillingLedgerEntry | yes | |
entries[].id | string (uuid) | yes | |
entries[].entry_type | topup · debit · refund · adjustment · subscription_grant · grant_expiry · deposit_hold · deposit_release · deposit_apply | yes | |
entries[].event_type | string,null | yes | The billable event type for a debit; null for the other movements. |
entries[].ref_uuid | string | yes | The billable event id for a debit, the payment idempotency key for a topup, YYYY-MM for a period grant. |
entries[].billable_event_id | string,null (uuid) | yes | The usage event that caused a debit; null for the other movements. |
entries[].cash_delta | string | yes | Signed decimal, as a string. |
entries[].grant_delta | string | yes | Signed decimal, as a string. |
entries[].deposit_delta | string | yes | Signed decimal, as a string. |
entries[].occurred_on | string (date) | yes | The UTC day the movement belongs to. |
entries[].recorded_at | string (date-time) | yes | When the entry landed. |
next_cursor | string,null | yes | Pass 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/invoiceSame 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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
period | query | string | no | The calendar month to invoice, YYYY-MM. Defaults to the current UTC month. |
format | query | csv | no | csv 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"
}
}