Billing¶
Usage-based billing from per-minute pod snapshots. Shuttle snapshots running pods every 60s to
Starbase, which stores the raw snapshot rows in Postgres (primary key snapshot_id — idempotent
ingestion and the dispute record) plus running totals; a monthly cron applies the plan credit and
generates a Stripe invoice. The ClickHouse audit trail is post-MVP
(§39.3 #71; the post-MVP block below).
Load-bearing — cost from resource requests, not labels
Cost is computed from the pod's resource requests (CPU/memory in the PodSpec), not from
labels — the starform.io/tier label is denormalized for display/analytics only
(§24.1). Tier name → price is a Starbase-side lookup from
resource requests at snapshot ingest (Calculation,
FR-087). Billing-staleness is watched by Grafana Cloud
(§35.5).
§36.1 End-to-End¶
flowchart TB
classDef built fill:#3434DC22,stroke:#3434DC,color:#5B5EE8;
classDef third fill:transparent,stroke:#808080,color:#808080;
classDef store stroke-dasharray:4 3,stroke:#808080;
SHUTTLE["Shuttle<br/>every 60s"]:::built
API["Starbase API"]:::built
PG[("Postgres<br/>raw snapshots (snapshot_id PK, bounded retention)<br/>+ running totals · dashboard + Stripe invoicing")]:::store
WORKER["Starbase Worker<br/>monthly cron · queries Postgres running totals"]:::built
STRIPE["Stripe<br/>generates invoice per customer · charges customer"]:::third
SHUTTLE -- "POST /api/v1/clusters/{id}/snapshots" --> API
API -- "upsert (idempotent)" --> PG
PG --> WORKER
WORKER -- "generate invoice" --> STRIPE
§36.2 Billing Model¶
- Per-minute billing (not per-second) — matches Railway, simpler operationally
- Instance-based — pod existence = billable, not CPU/memory utilization
- Snapshot reconciliation — periodic snapshots (not event-driven start/stop) avoids the fatal flaw of lost "stop" events causing infinite billing
- Idempotent ingestion — deterministic snapshot_id is the Postgres primary key, so retries (and FR-081 replay) upsert instead of double-counting
- Priced at ingest — each newly inserted Running row resolves its instance-size rate to cents in the same transaction (Calculation, FR-087); Shuttle payloads carry no pricing
- Raw rows are the MVP audit record — kept in Postgres with bounded retention (~60–90 days, partitioned by day; ~1.2M rows/day at full MVP scale). One write path at MVP means there is nothing to reconcile — the reconciliation job arrives with the post-MVP audit store (below)
Calculation¶
How snapshots become money (FR-087). Rates live in Starbase alone — the §25.2 payload carries no pricing; Shuttle never sees money.
- Phase rule: only
phase = Runningsnapshots bill; pending/terminating/failed pods are free. - Minutes: one Running snapshot (60 s cadence) = one billable pod-minute. A missed snapshot bills nothing — outages can only under-charge, never over-charge; §36.2's loss-bounded delivery is revenue-safe by construction.
- Rate:
per_minute = size_monthly ÷ (days_in_month × 1440)— the instance size's (Nova tier) monthly price normalized to the calendar month, so a pod running 24/7 bills exactly list price; partial usage prorates. Instance size sets the rate; the plan (Hobby/Pro) sets quotas, retention, and the monthly credit — never the per-pod rate. - Price at ingest: in the same transaction as the idempotent raw insert, each newly inserted
Running row adds its size's per-minute cents to
usage_totals. A replayed batch (FR-081) hits thesnapshot_idPK conflict → no insert → no increment. Fractional cents accumulate (NUMERIC); rounding is half-up, once, at invoice. - Recompute path: raw snapshot rows stay money-free physical facts — a bad rate-card entry is repaired by recomputing cents from raw rows × the corrected card within the retention window.
- Dedicated DBs: invisible to snapshots — the monthly cron materializes their cents from the
FR-076 ledger (
created_at → deleted_at, same calendar-month rate shape) intousage_totals(db_dedicated) before invoicing, so the invoice reads one table. - Egress — deferred: not billed at MVP, absorbed as COGS (the per-service measurement source is
the telemetry store, and billing stays Postgres-only until the audit trail —
§39.3 #71); the egress-COGS check rides the Financial
Model. Storage: no separate line — Mininova's 1 GiB PVC and dedicated-DB disk sit inside tier
prices. Both categories stay reserved in
usage_totals. - Invoice:
total = max(0, round(Σ amount_cents) − plan_credit)(FR-055/FR-056); totals under Stripe's US$0.50 minimum charge roll into the next month (Stripe lifecycle). Plan base fee + credit sizing = Financial Model.
§36.3 Edge Cases¶
| Edge case | How handled |
|---|---|
| Mid-month tier change | Pod recreated at new tier, naturally reflected in snapshots |
| Customer pod deleted | Snapshots stop containing that pod, no infinite billing |
| Shuttle disconnect | Batches queue in the bounded replay buffer and replay on reconnect (FR-081); loss is bounded by buffer depth (default 60 min), not outage duration. A Shuttle restart loses at most one interval (buffer is memory-only) |
| Billing dispute | Query the Postgres raw snapshot rows for exact pod-level history (bounded retention); post-MVP, the ClickHouse audit trail extends the window |
| Failed payment | Starbase Worker suspends services via desired state — the payload's suspended: true flag (§25.1); Shuttle scales to 0 and removes any HPA regardless of autoscaling, so suspension stops autoscaled services too (un-suspend restores the HPA). Retry cadence before suspension → Stripe lifecycle |
Tables¶
Raw snapshots are the dedup boundary and the MVP dispute record; totals are what the invoice cron
reads. workspaces.stripe_customer_id lives with the identity tables (§15.7).
billing_snapshots ( -- §25.2 ingest; PG puts the partition key in the PK
snapshot_id TEXT NOT NULL, -- hash(project+service+pod+ts) → idempotent upsert (FR-081-safe)
snapshot_at TIMESTAMPTZ NOT NULL,
cluster_id UUID NOT NULL,
workspace_id UUID NOT NULL, -- billing boundary (§24.1)
project_id UUID NOT NULL,
environment TEXT NOT NULL,
service_id UUID, -- service pods
database_id UUID, -- Mininova DB pods (see Database line items)
pod_id TEXT NOT NULL,
tier TEXT,
phase TEXT,
PRIMARY KEY (snapshot_id, snapshot_at)
) PARTITION BY RANGE (snapshot_at); -- daily partitions; kept ~60–90 d, then dropped
usage_totals ( -- money accumulator; the monthly cron reads these (FR-087)
workspace_id UUID NOT NULL,
month DATE NOT NULL,
category TEXT NOT NULL, -- compute | build_minutes | db_dedicated | egress | storage
amount_cents NUMERIC NOT NULL DEFAULT 0, -- fractional cents, priced at ingest; rounded half-up once at invoice
PRIMARY KEY (workspace_id, month, category)
);
usage_events ( -- itemized events, physical quantities (e.g. §16.7 build minutes);
-- converted to cents when folded into usage_totals
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces(id),
category TEXT NOT NULL,
quantity NUMERIC NOT NULL,
ref_id UUID, -- e.g. build_id
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
API endpoints¶
Conventions (auth, errors, pagination, permission notation) → Dashboard API.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /workspaces/{id}/usage |
Owner / Admin / Billing | Current-month totals by category (usage_totals) |
| GET | /workspaces/{id}/usage/projects |
Owner / Admin / Billing | Spend breakdown per project (§15.3) |
| GET | /workspaces/{id}/invoices |
Owner / Admin / Billing | Invoice history — period, amount, status, Stripe hosted-invoice link |
Stripe lifecycle¶
One Stripe Customer per workspace (workspaces.stripe_customer_id,
§15.7). A valid card is required at the first paid action (pairing with the
§39.2 #58 valid-payment gate); Hobby usage within the plan
credit needs none.
Monthly invoice cron (§14): materialize dedicated-DB cents from
the FR-076 ledger → round per category and subtract the plan credit (Calculation) →
zero total: no invoice; under US$0.50 (Stripe's minimum charge): carry the balance into next
month, no charge attempt → otherwise create the Stripe invoice with auto_advance: true — Stripe
finalizes, charges the default payment method, and owns retries from there.
Webhooks consumed (signature-verified at the §13 API binary;
deduped via stripe_events — the webhook_deliveries pattern):
| Event | Effect |
|---|---|
invoice.paid |
mark paid; clear dunning; clear a payment-caused suspended — Shuttle restores workloads + HPA on its next tick (§36.3) |
invoice.payment_failed |
enter dunning — dashboard banner + email; Smart Retries continue |
payment_method.attached |
satisfies the valid-payment gate |
customer.subscription.updated / .deleted |
plan bookkeeping (changes effective next cycle) |
Dunning = Stripe Smart Retries:
ML-timed retries, default 8 attempts within 2 weeks (window configurable 1 week–2 months). On the
final failure Starbase sets suspended: true through desired state (§36.3 — halts autoscaled
services too) plus the dashboard banner; a later successful payment clears it automatically. No
manual step in either direction.
Proration & refunds (MVP): plan changes take effect next cycle — the credit is per-month, never prorated. Mid-month instance-size changes need no proration machinery: snapshots at the new size simply accrue at the new rate (Calculation). Refunds are manual via the Stripe dashboard; no API surface.
stripe_events (
id TEXT PRIMARY KEY, -- Stripe event id (evt_…) → idempotent consumption
type TEXT NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
ClickHouse audit trail — post-MVP¶
Deferred — and re-routed (§39.3 #71)
When snapshot volume outgrows the Postgres raw-row window, the immutable audit trail lands in the
regional ClickHouse — written in-region by Shuttle through the OTel gateway (per-cluster token;
the gateway's on-disk queue rides out ClickHouse outages; the gateway stays the sole ClickHouse
writer), not by the central API. Because the Postgres and ClickHouse copies then arrive by
independent paths, the nightly PG↔CH reconciliation detects delivery gaps (a batch that never
reached Starbase), not just dual-write bugs — "in ClickHouse, not in Postgres" is a delivery alarm,
not noise. Design record: specs/2026-07-09-egress-only-telemetry-and-scope-cuts-design.md.
Database line items¶
Two mechanisms, split by DB tier (Managed Databases):
- Mininova (containerized throwaway) instances are pods — they bill through the existing per-minute pod snapshots above, priced from their resource requests like any workload. Nothing new in the pipeline.
- Micronova+ (dedicated DO Managed) instances are invisible to Shuttle snapshots, so they are
metered per-minute from the Starbase provisioning ledger — the
databasestable'screated_at→deleted_atwindow — by the same monthly cron (FR-076), which materializes the cents intousage_totals(db_dedicated) before invoicing (Calculation). The ledger is authoritative; billing MUST NOT depend on snapshots for these.
DB sell prices are owned by the Financial Model (the PRD records DO list costs only as vendor constraints — Depot/Tigris precedent).
Tiers & pricing¶
Pod instance sizes are the customer-facing Nova tiers (§1). Price maps to the pod's resource requests at billing time (see the load-bearing note above) — the tier name is a denormalized label only.
| Tier | Nova Name | CPU | RAM | Price |
|---|---|---|---|---|
| Hobby (shared) | Mininova | Shared | 512 MB | $5/mo |
| Dedicated entry | Micronova | 0.5 vCPU | 512 MB | $18/mo |
| Dedicated mid | Nova | 1 vCPU | 2 GB | $36/mo |
| Dedicated growth | Supernova | 2 vCPU | 4 GB | $85/mo |
| Dedicated scale | Hypernova | 4 vCPU | 8 GB | $175/mo |
| Dedicated premium | Ultranova | 4 vCPU | 16 GB | $250/mo |
Cross-references
Per-pod cost is derived from resource requests, not the tier label →
§24.1 · the snapshot is delivered through the desired-state
loop → §32 · Shuttle's billing-staleness gauge →
§26.1 · the post-MVP ClickHouse audit path →
§39.3 #71 · Nova tier names →
§1. Canonical map:
Canonical Sources.