ADR-032: Public cost-dashboard projection — token shape + sanitization contract
Status: Accepted (corrected 2026-08-03, amended 2026-10-12) Date: 2026-05-14 Related: ADR-021 (hosted-invoice public token shape), ADR-031 (per-plan bill_timing)
Summary
Section titled “Summary”GET /v1/public/cost-dashboard/{token} returns one customer’s current-period usage and cost as sanitized JSON that a partner can show in its own app. The hc_pcd_ token is the only credential: rotating it ends the old URL at once, and an invalid or rotated token gets the same 401. Fields come from an explicit allowlist, so email, names, billing profile and metadata never leak, and the endpoint shares the hosted invoice page’s 60-per-minute-per-IP limit. The token is stored only as a SHA-256 hash (migration 0172) and is shown once, when it is rotated. Status: active, amended 2026-10-12: the response also carries the customer’s credit position, including an estimate of what remains after this period’s usage.
Context
Section titled “Context”The cost-dashboard was half-built: the operator-facing surface (GET /v1/customers/{id}/usage + <CostDashboard> React component) shipped earlier; the public surface for partners to embed in their own apps did not. The token column + Customer model field landed in migration 0064 but no public route, rotation endpoint, or sanitized projection existed.
This is wedge-relevant work — cost visibility is the AI-native pitch — and demo-blocking. MANUAL_TEST FLOW CU8 documented the expected shape; the code lagged.
Decision
Section titled “Decision”Ship a single sanitized JSON endpoint for the public projection. Defer the embeddable React widget until a real design partner asks for it (the JSON is consumer-ready; partners can render their own UI today).
- Format:
hc_pcd_+ 64 hex (32 bytes entropy). Same shape as the hosted-invoice public token (ADR-021). - Persisted plaintext in
customers.cost_dashboard_tokenwith a partial UNIQUE index. - Rotation invalidates the previous token IMMEDIATELY — no grace window. Read-only surface; the rotation intent is “stop the previous URL right now.”
- Audit log entry on rotation (
action=rotate,metadata.surface=cost_dashboard_token). Plaintext token NEVER in the audit row.
Lookup
Section titled “Lookup”- Token lookup uses RLS-bypass (
TxBypass). The token IS the credential — no tenant context yet, and the returned customer’stenant_idis what scopes everything downstream. - 401 envelope identical for invalid / never-existed / rotated tokens (anti-enumeration). Same shape Hardcount uses for revoked API keys.
- Prefix-mismatch fast-path: any token not starting with
hc_pcd_→ 401 without DB lookup.
Sanitization contract
Section titled “Sanitization contract”The projection is built by composing the existing CustomerUsageService.Get (same math the operator dashboard uses → dashboard math == public math) then stripping PII at the assembler boundary.
| Field | Public? | Why |
|---|---|---|
| customer_id, tenant_id | yes | Caller has the token; these are not secrets. |
| billing_period { start, end, source } | yes | Cycle-bound context. source is "subscription" or "no_subscription". |
| subscriptions[].{id, plan_name, currency, period} | yes | What’s running and when the cycle closes. |
| usage[].{meter_key, meter_name, unit, currency, totals, rules[]} | yes | Cost attribution — the whole point. |
| totals[], projected_total_cents | yes | Headline number partners surface to their customer. |
| NO | PII | |
| display_name | NO | PII |
| external_id | NO | Caller chose this id privately |
| metadata | NO | Caller-controlled — could carry anything |
| billing_profile | NO | Legal name, address, tax_id |
| warnings | NO | Operator-facing tech messages |
| plan_id, rating_rule_version_id | NO | Internal IDs, not useful to the partner |
The mapping happens in internal/usage/cost_dashboard.go::CostDashboardAssembler.GetByToken. Adding a new field to the operator-facing CustomerUsageResult does NOT automatically leak it to the public surface — the assembler walks an explicit allowlist of fields. Future PII fields stay private by default.
Empty state
Section titled “Empty state”No active subscription → 200 with billing_period.source = "no_subscription" and empty arrays. Not a 404, not a 5xx — the partner widget renders a clean empty state rather than handling an error code.
Rate limit
Section titled “Rate limit”Mounted under the existing hostedInvoiceRL 60/min/IP bucket. Tighter than the general 100/min for the same reason hosted invoice tightened: payment-adjacent surfaces are higher-value targets, and the widget may poll.
Consequences
Section titled “Consequences”Positive:
- Hardcount can now demo the AI-native cost-visibility pitch end-to-end. Partner embeds the JSON into their own app; rotation gives the operator a kill switch.
- Sanitization is explicit (allowlist, not denylist) — future operator-facing additions don’t leak by accident.
- Same token + rotation shape as hosted invoice (ADR-021) — one mental model for operators.
Negative:
- No widget. Partners build their own renderer or wait for the React component to ship. Tradeoff: shipping the JSON now unblocks the demo; the widget is uncertain UX work that benefits from real DP feedback before locking in.
- Token plaintext in the DB. Same tradeoff hosted invoice made; 256-bit entropy makes brute-force infeasible and the read-only surface limits blast radius if the column leaks.
Industry reference
Section titled “Industry reference”Corrected 2026-08-03 (FLOW CU8 parity check). This section previously claimed “Lago / Orb / Metronome: none ship a partner-embeddable cost dashboard out of the box. Hardcount is shipping ahead of the peer set here.” That is false, and it was the line carrying the wedge argument. All three ship exactly that, two of them as literally an embeddable iframe URL. The re-checked findings, each verified against official documentation, are below. The decision still stands — see why under “Verdict” — but it stands on different grounds than “we’re ahead.”
- Stripe — API only, no end-customer usage page. The customer portal covers payment details, subscriptions and invoice history (“Pay, download, and view current and past invoices”), but shows no current-period usage or spend (customer-management). For usage it ships data, not pixels: “Use the Meter Usage Analytics API to query and analyse your customers’ meter usage data. This enables you to build custom usage dashboards” (usage-based/analytics).
- Metronome — embeddable widget and API.
POST /v1/dashboards/getEmbeddableUrlreturns a URL to display “through an iframe within your internal billing UI”, with acolor_overridesarray to match the host design; dashboards areusage(30/60/90 days),invoices(90 days of history) andcommits_and_credits(customer-dashboards). - Orb — hosted page, embeddable, and API. “Orb generates a signed, expiring URL to an invoice portal and a signed, non-expiring URL to a customer portal… These portals can be directly accessed by your customers”, showing invoices plus current usage (invoice-portal). Orb also documents the build-your-own path as first-class, for Hardcount’s exact use case: “Retrieving usage can be especially useful to power in-product visibility for your end customers” (usage-visibility).
- Lago — customer portal with an embeddable iframe
(
/customers/:external_customer_id/portal_url, token expires after 12 hours), showing “a detailed usage report, showing the number of units consumed and associated costs” (customer-portal). Listed on the pricing page under Premium only, not the open-source tier (pricing) — so for a self-hosted OSS comparison the gap narrows, though we did not verify license enforcement in their code. Correction (2026-10-23): lago-api has no licence check on the portal. Lago’s pricing page lists the portal as premium, but the service that makes the portal link checks no licence. Checked in lago-api2e52dfc(2026-10-22):app/services/customer_portal/generate_url_service.rb:13-21. - What an AI company’s customers already expect, as the ICP reference point:
OpenAI ships a console usage dashboard with per-project breakdowns, spend
categories, CSV export and 1-minute granularity
(usage dashboard);
Anthropic ships Usage and Cost console pages plus a Usage & Cost Admin API
with
1m/1h/1dbuckets (usage-cost-api).
Verdict: BEHIND on the rendered surface, AT PARITY on the data primitive. Two gaps are real, and only one of them is a UI question:
- No pixels. Every usage-based peer ships something an end customer can
look at; Hardcount ships JSON. This stays deferred and the reasoning is
unchanged — Stripe, the volume leader, ships only the API and expects the
vendor to build the dashboard, and an AI-infra design partner has its own
product UI that an iframe fights (Metronome’s
color_overridesexists because of exactly that friction). For a self-hosted product whose users are engineers, JSON is the correct primitive and a widget is a convenience layer. Trigger unchanged: build when a design partner asks. - Current period only — and this one is a data gap, not a UI gap. Peers expose history and time series (Metronome 30/60/90 days, Orb history plus current, both AI consoles down to minute granularity). Hardcount’s projection cannot answer “what did I spend yesterday versus today”, which is table stakes for the AI-infra ICP this endpoint exists to serve. Deferred with trigger: if the first design partner is AI infrastructure, historical time-series on this projection is the fast-follow — it is cheap relative to a UI and is where the peer set is genuinely ahead.
Token shape is unaffected by the above: Orb’s customer-portal URL is likewise non-expiring, so Hardcount’s non-expiry is at parity. The plaintext-at-rest storage noted under Negative is Hardcount-specific and remains gated on the first production deploy.
Amendment 2026-10-12: the projection carries the credit position
Section titled “Amendment 2026-10-12: the projection carries the credit position”A user asked for a balance their own customers can see (issue #86). The projection now has a credits object: the ledger balance, what is left of the prepaid commit and of promotional credit, and an estimate of what remains after this period’s usage so far.
- The estimate exists because the ledger balance does not move during a period: credit is drawn down when the invoice is issued. A page that wants a “live” figure needs the balance minus usage so far.
- It is labelled an estimate. The projection excludes tax, and totals in different currencies are added together.
- A failed credit read fails the whole response. A zero balance shown to a customer who holds credit would be wrong and silent.
- The endpoint is still polled. Push updates were asked for as a preference and are not built.