Billing
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.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/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/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
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. |
{
"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).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Spend-alert threshold crossings recorded in the current billing epoch
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). |
{
"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).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Invoiceable line items over a caller-chosen window (NOT the invoice)
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 (3-seat billing floor), 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). 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. ?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 | |
period.to | string (date-time) | no | |
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[].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 |
{
"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"
},
"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).
{
"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).
{
"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).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}The invoice for one calendar month, with the period set by the SERVER
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
{
"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",
"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"
}
]
}live
{
"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"
},
"line_items": [
{
"kind": "seats",
"cadence": "monthly",
"source": "ledger_snapshot",
"occupied_seats": null,
"billable_seats": 5,
"seat_minimum": 3,
"unit_price_usd": 119,
"amount_usd": 595
}
],
"total_usd": 595
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"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).
{
"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).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}