Customer Usage Endpoint — Technical Design
- Decided: add
GET /v1/customers/{id}/usage. One call returns a customer’s usage and its cost, per meter and per pricing rule, matched to rules the same way the cycle close does. It covers the current billing period unless you passfromandto.- Shipped: v0.1.0, in #26; the dashboard view came in #27.
- Current docs: the usage routes in The Hardcount API, and
/v1/customers/{id}/usagein API endpoints. Where this page and the code disagree, the code wins.
Design document, written 2026-04-26 before the build. The endpoint (
GET /v1/customers/{id}/usage) has shipped; no ADR decided it, so the code is the current record:internal/usage/customer_usage.goandcustomer_usage_handler.go, with the dashboard inweb-v2/src/components/CostDashboard.tsx. Later decisions that changed it: ADR-032 added the public, token-protected cost-dashboard view of this data; ADR-044 made onetoken_typedimension the canonical token shape (the example response already uses it; leftover names likegpt4_input_uncachedpredate it); ADR-045 made per-unit rates decimal; ADR-054 added the full-precision per-unit price to each rule row; ADR-070 prices each rule (and any customer override) at the version in force at the start of the window, the same way invoices do. The ADR-012 amendment of 2026-10-22 starts an invoice’s usage at the trial end, or where another subscription of the customer stopped covering the meter. This report still starts at the period start. Take the first paid period after a trial that ends mid-day, or a subscription created the day another one ended. For those, the report also shows that day’s earlier usage, which the invoice does not bill. Each meter row names the meter byevent_name(it wasmeter_key), and each rule bills under the meter’s one aggregation (ADR-130 §2). Related designs:docs/dev/design/multi-dim-meters.md,docs/dev/design/recipes.md.
Motivation
Section titled “Motivation”A buyer evaluating Hardcount asks the same question on day one: “How do I show a customer their current usage and what they’ll be charged?” Stripe Billing’s answer is a hosted Customer Portal page — but that’s locked to Stripe Billing, which Hardcount does not use (per the ADR — Architecture Decision Record — adopting the PaymentIntent-only pattern). Buyers self-hosting Hardcount today have no first-party answer. They hand-roll a SQL query against usage_events and pray the dimension-match logic (the mapping of each event’s dimensions to the pricing rule that rates it) stays in sync with their pricing rules.
This is the second flagship developer-experience surface, alongside recipes. Where recipes collapse “set up billing” to one POST, GET /v1/customers/{id}/usage collapses “show me what this customer used and owes” to one GET. The cost-dashboard component is the in-product manifestation; the API itself unblocks every operator who needs the same data in their own portal, in Slack alerts, in CSV exports.
The dimension-match-aware aggregation is the part that can’t be hand-rolled: the rules-ranked LATERAL JOIN in internal/usage/postgres.go:236–279 is non-trivial. Any external SQL would silently drift from canonical pricing the moment a tenant adds a meter_pricing_rule (a row binding a dimension combination to its price). This endpoint is the supported way to get the right answer.
This endpoint composes the multi-dim aggregation surface and the invoice-line-item fan-out so callers can distinguish billed from not-yet-billed usage in a single response.
- One call answers “what did this customer use?” Per-meter aggregates over a caller-chosen window, with the same dimension-match → rating-rule binding used by the cycle scan (the billing engine’s pass that rates a cycle’s usage into invoice line items). Operators read the same numbers their invoices will. The one exception is the start-day usage noted at the top. No SQL drift.
- Cost transparency on the same response. Each per-meter (and per-rule, for multi-dimensional meters) line includes the rated
amount_centsso dashboards render$12.34 — 1.2M tokens · gpt-4 input · uncachedwithout a second pricing call. - Period defaults to current billing cycle. With no query params, returns the live customer’s current cycle so the obvious dashboard call is the cheapest one to write.
?from=…&to=…overrides for historical / arbitrary windows (last-90d, audit, etc.). - Subscription context co-emitted. Response includes the plan(s) the customer is on plus the cycle boundaries, so a dashboard can render “Plan: AI API Pro · cycle Apr 1 → May 1 · 87% through” without follow-up calls.
- RLS-safe. RLS (Row-Level Security) is the Postgres mechanism that filters every query down to the calling tenant’s rows. All queries scope through
BeginTx(ctx, postgres.TxTenant, tenantID); the response cannot leak across tenants even when an operator passes a customer ID from another tenant — the lookup just 404s. - Cheap. Constant-bounded SQL (one aggregate per meter on the customer’s plans). No event-by-event scan in the hot path. Live dashboards refresh every few seconds without melting the DB.
- Documented contract for downstream consumers. This is the API the cost-dashboard component calls and the portal surface uses.
Non-goals (deferred)
Section titled “Non-goals (deferred)”- Time-series breakdown. v1 returns aggregates over the window, not per-day buckets. The dashboard’s first iteration is “current period total + last period total + plan boundary”; sparkline data lands in v2. Postgres aggregation by
date_trunc('day', timestamp)is cheap when needed — we just don’t wire it yet. - Forecast / projection. “At current rate, this customer will use 3.4M tokens by cycle end.” Pure UI math on top of the v1 response. Punt.
- Cross-customer rollups. No “top customers by usage” query. Use the
/v1/usage-eventslist endpoint with appropriate filters; or, when a real user need shows up, designGET /v1/usage/top-customersseparately. This endpoint is per-customer. - Threshold alerts. “Notify me when this customer hits 80% of plan cap.” Webhook concern (
usage.threshold_crossed) — owned by the dunning (failed-payment follow-up) / notification surface, not this endpoint. Defer to Week 6+. - Past-cycle line-item replay. v1 returns aggregates, not the canonical
invoice_line_itemsrows for closed cycles. The latter is whatGET /v1/invoices/{id}already serves; no double-implementation here. - Currency conversion. Amounts are returned in the plan’s currency. If a tenant has multi-currency plans, each line carries its own
currencyfield; clients render whichever they need.
Today’s surface (in repo)
Section titled “Today’s surface (in repo)”internal/usage/store.go::AggregateForBillingPeriod(ctx, tenantID, customerID, meterIDs, from, to)— already returnsmap[string]decimal.Decimalper meter. Multi-dim aware viameter_pricing_rulesLATERAL JOIN (internal/usage/postgres.go:236–279).internal/customer/store.go::Get(ctx, tenantID, id)— RLS-safe customer lookup.internal/subscription/store.go::List(ctx, tenantID, filter)— list active subs for a customer (filter bycustomer_id).internal/domain/pricing.go::ComputeAmountCents(rule RatingRuleVersion, quantity decimal.Decimal)— pricing engine; same path the cycle scan calls.internal/billing/engine— orchestrates cycle scan; reads the same store layer this endpoint composes from. The cycle scan is the canonical reference for “how to rate a customer’s events”, and this design intentionally reuses the same store calls.
This endpoint is composition, not new aggregation logic. The hard SQL already exists. The win is exposing it on a stable wire contract so operators (and Track B’s dashboard — Track B is the parallel frontend workstream, Track A the backend) don’t reinvent it.
API surface
Section titled “API surface”Wire-contract conventions (consistent with
/v1/*, seedocs/dev/design/recipes.md§ wire-contract):
- Snake-case JSON keys, struct-tag enforced. Picker-side TS types match.
- Customer identity is the customer ID (
hc_cus_…), not the external ID (the identifier the tenant’s own system uses for the customer). Use?external_id=if you need external-ID lookup; defer for v2 or wrap as a separate endpoint.- Period bounds are RFC 3339 (
?from=2026-04-01T00:00:00Z&to=2026-05-01T00:00:00Z). Both inclusive offrom, exclusive ofto. If onlyfromis supplied → 400 (we don’t guess “to=now”). If both omitted → defaults to the customer’s current billing cycle.- Empty results are
[], nevernull. Aligns with the existing list endpoints.
GET /v1/customers/{id}/usage — current cycle (default)
Section titled “GET /v1/customers/{id}/usage — current cycle (default)”GET /v1/customers/hc_cus_abc123/usageAuthorization: Bearer <secret_key>Response 200:
{ "customer_id": "hc_cus_abc123", "period": { "from": "2026-04-01T00:00:00Z", "to": "2026-05-01T00:00:00Z", "source": "current_billing_cycle" }, "subscriptions": [ { "id": "hc_sub_xyz", "plan_id": "hc_pln_pro", "plan_name": "AI API Pro", "currency": "USD", "current_period_start": "2026-04-01T00:00:00Z", "current_period_end": "2026-05-01T00:00:00Z" } ], "meters": [ { "meter_id": "hc_mtr_tokens", "meter_key": "tokens", "meter_name": "Tokens", "unit": "tokens", "currency": "USD", "total_quantity": "1234567.000000000000", "total_amount_cents": 3704, "rules": [ { "rating_rule_version_id": "hc_rrv_gpt4_input_uncached", "rule_key": "gpt4_input_uncached", "dimension_match": { "model": "gpt-4", "token_type": "input" }, "quantity": "1000000.000000000000", "amount_cents": 3000 }, { "rating_rule_version_id": "hc_rrv_gpt4_output", "rule_key": "gpt4_output", "dimension_match": { "model": "gpt-4", "token_type": "output" }, "quantity": "234567.000000000000", "amount_cents": 704 } ] } ], "totals": { "amount_cents": 3704, "currency": "USD" }, "warnings": []}rules is the per-rule breakdown for multi-dim meters; for a flat single-rule meter the slice is length 1 with no dimension_match. total_quantity is the sum across rules; total_amount_cents is the sum across rules’ amount_cents. Multi-currency plans (rare) emit one entry per currency under meters with currency set; totals becomes a list of {currency, amount_cents} rather than a scalar.
warnings is the same shape as recipes’ preview warnings — non-fatal conditions the caller should see. Examples: a meter has events outside any rule’s dimension_match and is falling through to the meter’s default aggregation; a subscription is past its cycle end and a re-cycle hasn’t run; a currency mismatch between subscription and rating rule. Empty array in v1 if everything is clean.
GET /v1/customers/{id}/usage?from=…&to=… — explicit window
Section titled “GET /v1/customers/{id}/usage?from=…&to=… — explicit window”GET /v1/customers/hc_cus_abc123/usage?from=2026-01-01T00:00:00Z&to=2026-04-01T00:00:00ZAuthorization: Bearer <secret_key>Same response shape; period.source is "explicit". subscriptions reflects whatever was active during any portion of the window; if the customer changed plans mid-window, both plans surface (sorted by current_period_start ascending).
Error shapes
Section titled “Error shapes”404 customer_not_found— no customer with this ID for the tenant. Same shape as other/v1/customers/{id}404s.400 invalid_period—fromafterto, partial bounds (fromwithoutto), unparseable RFC 3339, or window > 1 year (sanity bound — overrideable via internal?max_window=trueonce we hit a real audit need).400 customer_has_no_subscription— caller passed no period and the customer has zero active subscriptions, so there’s no canonical “current cycle”. Hint message: “Pass?from=and?to=to query usage outside a billing cycle.”
Internals
Section titled “Internals”Composition
Section titled “Composition”// internal/usage/service.go (sketch)func (s *Service) GetCustomerUsage( ctx context.Context, tenantID, customerID string, period CustomerUsagePeriod,) (CustomerUsageResult, error) { cust, err := s.customers.Get(ctx, tenantID, customerID) if err != nil { return CustomerUsageResult{}, err } // 404 propagates
subs, err := s.subscriptions.ListActiveForCustomer(ctx, tenantID, customerID) if err != nil { return CustomerUsageResult{}, err }
from, to, source, err := resolvePeriod(period, subs) // current cycle vs explicit if err != nil { return CustomerUsageResult{}, err }
// Walk the union of meter IDs across the customer's subscribed plans. meterIDs := collectMeterIDs(subs) rated, err := s.usage.RateForCustomer(ctx, tenantID, customerID, meterIDs, from, to) if err != nil { return CustomerUsageResult{}, err }
return assembleResult(cust, subs, period, rated), nil}usage.RateForCustomer is a thin wrapper that calls the existing AggregateByPricingRules per meter, hands the resulting (rule, quantity) pairs to pricing.ComputeAmountCents, and assembles the per-rule breakdown. No new SQL. The rating-rule lookup uses the same priority-ordered scan as the cycle engine, so dashboard math == invoice math.
Period resolution
Section titled “Period resolution”- Default (no
from/to): each subscription is rated over its own current period (ADR-130 §10). The response’s period runs from the earliest start to the latest end of those periods, and each meter’s daily buckets are read over its own subscription’s period. With no rated subscription, the period is the current cycle of the subscription that started last. - Explicit: parse
from/to, validatefrom < to, validate window ≤ 1 year (configurable via env at v2). - Cap to 1 year because the LATERAL JOIN cost grows linearly with row count; one year of high-volume usage events on a multi-dim meter is already 100M+ rows for active tenants. We surface this as a 400 rather than silently degrading.
Standard BeginTx(ctx, postgres.TxTenant, tenantID). The customer lookup naturally 404s for cross-tenant IDs (RLS hides the row). No new policy.
Caching
Section titled “Caching”No caching in v1. Each call hits Postgres. The aggregate query is fast (~tens of ms on dev hardware for 1M events). Add caching only if a real performance issue emerges; the failure mode of a stale dashboard is worse than the cost of the live query.
Unit tests
Section titled “Unit tests”resolvePeriodtable-driven: explicit window, default → current cycle, default → no subscription → 400, partial-bounds → 400, window > 1 year → 400,from > to→ 400.assembleResultshape: multi-currency plans emit one meter entry per currency; total aggregations across rules sum correctly; empty events → 0-amount lines, not omitted entries.
Integration tests (real Postgres)
Section titled “Integration tests (real Postgres)”- Single-meter, single-rule. Seed 1000 events on a seat-based / flat-rate meter. Verify
total_quantityandtotal_amount_centsmatch the cycle-scan output for the same period. - Multi-dim meter. Seed events split across 3 dimension combinations on
anthropic_style. Verify the per-rule breakdown sums tototal_amount_centsand matchesusage.AggregateByPricingRulesexactly. This is the parity test that catches drift between dashboard math and invoice math. - Cycle alignment. Seed events both inside and outside the cycle window. Default call only counts in-cycle. Explicit-window call counts the requested range.
- Cross-tenant isolation. Customer ID exists for tenant A; tenant B’s GET → 404. RLS-by-construction.
- No subscription, no period. Customer with zero active subs + no query params → 400. With explicit
?from=&to=→ 200 withsubscriptions: []and per-meter aggregates by raw event scan. - Plan transition mid-window. Customer was on plan X (Apr 1–15) then plan Y (Apr 15–30). Explicit window Apr 1 → May 1 returns both subscriptions; per-meter aggregates span both.
- Closed cycle parity. A previously billed cycle’s aggregate matches the sum of
invoice_line_itemsfor the cycle’s invoice (small drift tolerance for any rounding mode differences — should be zero forflatrules).
End-to-end test (against running stack)
Section titled “End-to-end test (against running stack)”Delivered as internal/usage/customer_usage_integration_test.go (real Postgres: ingest across dimension combinations, GET /v1/customers/{id}/usage asserted against expected fixtures) plus MANUAL_TEST FLOW S2 (the matching scripted walkthrough in MANUAL_TEST.md) for the running-stack smoke. The standalone e2e binary this section originally proposed (cmd/hardcount-customer-usage-e2e, or extending hardcount-recipes-e2e) was never built — neither ever existed in git.
Migrations
Section titled “Migrations”None. The schema (usage_events, meter_pricing_rules, subscriptions, plans) is sufficient. This is a read-side endpoint over existing tables.
Performance
Section titled “Performance”- One aggregate query per meter on the customer’s subscribed plans. For a typical customer on 1 plan with 1 multi-dim meter and 12 rules, the LATERAL JOIN runs once over the customer’s in-cycle events.
usage_eventsis already indexed on(tenant_id, customer_id, timestamp)— the period scan is a range scan on this index, not a full-table scan.- Target: < 100ms p95 for the typical customer (10K events / cycle). Target: < 500ms p99 for the heavy customer (1M events / cycle, 12 dimension rules). Beyond that, we add per-day rollups (deferred to v2 if it ever becomes a problem).
- No N+1:
subscriptions.ListActiveForCustomeris one query; the per-meter aggregate is one query per meter (typically 1–3). No per-event call-out.
Decimal & numeric considerations
Section titled “Decimal & numeric considerations”Quantity is decimal.Decimal (NUMERIC(38,12)) per ADR-005, marshaled as a string ("1234567.000000000000") to preserve precision. Amounts are integer cents per ADR-005; the rating-rule mode (flat, graduated, package) determines how the aggregated quantity becomes cents. All math runs through pricing.ComputeAmountCents; nothing is recomputed here.
Open questions
Section titled “Open questions”- Should the endpoint accept
external_idin the path (e.g.GET /v1/customers/by-external-id/{external_id}/usage)? Proposal: no for v1. Add a separate lookup endpoint or a?customer_external_id=filter on the existingGET /v1/customersendpoint if a partner asks. Avoids two paths to the same resource. - Should
rules[].dimension_matchecho the rule’s match expression, or the raw event dimensions seen? Proposal: the rule’s match expression — that’s the canonical pricing identity. The dashboard already knows what dimensions exist on the meter. Echoing observed values would be log data, which belongs on/v1/usage-events. - Should we expose pre-cycle / not-yet-billed usage separately from billed? Proposal: not in v1. The “current period” answer already implies “not yet billed”. For closed cycles, the matching invoice is the source of truth for billed numbers, and the usage aggregate matches it (parity test above). Two-section responses add UI complexity for no clear win.
- Does the response need a
next_invoice_estimateblock (projected end-of-cycle cents)? Proposal: no — pure UI math. The dashboard has the rate (cents-per-unit per rule) and the elapsed-vs-total cycle ratio; it can extrapolate. Server-side projection invites disagreement with whatever heuristic the dashboard wants. - Pagination on
meters[]? Proposal: no. A customer is on at most a handful of meters; even the heaviest tenant we can imagine has ≤ 20. If we hit a real catalog-size customer, paginate then. - Should the endpoint live under
/v1/customers/{id}/usageor/v1/usage/by-customer/{id}? Proposal:/v1/customers/{id}/usage— it composes naturally with the rest of the customer namespace and matches Stripe’s convention (/v1/customers/{id}/balance_transactions). The /usage namespace stays for raw event ingest/list. - Should
total_amount_centscome from a freshComputeAmountCentscall or from the matchinginvoice_line_itemsif the cycle is closed? Proposal: always fresh compute. If the customer’s pricing was edited mid-cycle, the dashboard wants the live answer; the invoice has the historical answer. Cycle-closed parity test guards drift. - Should warnings include “subscription cycle has rolled over but
current_period_*hasn’t been refreshed yet”? Proposal: yes, surfaces a real ops issue (cycle scanner stuck) without breaking the response. Format:cycle_refresh_laggingwarning code + the offending subscription ID.
Implementation checklist
Section titled “Implementation checklist”-
internal/usage/service.go—GetCustomerUsage+RateForCustomercomposing existing store calls. -
internal/usage/handler.go—GET /v1/customers/{id}/usageroute. -
resolvePeriodhelper + unit tests. - Integration tests: single-meter parity, multi-dim parity, cycle alignment, cross-tenant 404, no-sub + explicit window, plan transition, closed-cycle parity.
- End-to-end assertion fixture against the running stack.
- OpenAPI spec update (
api/openapi.yaml). - CHANGELOG entry + public changelog rollup.
Track B unblock
Section titled “Track B unblock”Track B can scaffold the cost-dashboard against this design today. The contract Track B should mock:
GET /v1/customers/{id}/usage(no params) → response shape above withperiod.source: "current_billing_cycle".GET /v1/customers/{id}/usage?from=…&to=…→ same shape withperiod.source: "explicit".- 400 on
customer_has_no_subscriptionwhen neither cycle nor explicit window is resolvable. - 404 on cross-tenant / unknown customer.
The dashboard shape Track B should aim at:
- Header: customer name + plan + “cycle X of Y days · Z% through”.
- Big number: cycle-to-date
totals.amount_centsformatted intotals.currency. - Per-meter cards:
meter_name · total_quantity · total_amount_cents. Click → expanded view ofrules[]with dimension chips and per-rule cents. - Filters: cycle (default) / last cycle / last 90 days / custom — all map to the same endpoint with different
from/to.
Track B can ship the UI against a mocked API (MSW — Mock Service Worker — handlers seeded from the example response above) before Track A finishes the backend, then swap to the real API at integration time. Same design-first / RFC parallel-work pattern as recipes.
Review status
Section titled “Review status”- Track A author: drafted 2026-04-26
- Track B review: pending — Track B can scaffold the cost-dashboard against this design without waiting for implementation
- Human review: pending — please flag any open question to resolve before Week 5 starts