Skip to content

Billing

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

Invoiceable line items.

Endpoints

MethodPathSummary
GET/v1/billing/accountBalance, the cap in force, spend against it and the enforcement mode
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/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).

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

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

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

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

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)no
period.tostring (date-time)no
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[].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
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"
  },
  "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).

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 must be at least 10 minutes out"
  }
}

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

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"
  },
  "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).

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

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 must be at least 10 minutes out"
  }
}

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