Skip to content

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
Diagram — Billing flow, end to end (MVP). Shuttle snapshots running pods every 60s → Starbase API upserts raw snapshot rows + running totals into Postgres → the Worker's monthly cron reads the totals and generates a Stripe invoice per customer. The ClickHouse audit trail joins post-MVP (§39.3 #71). Brand-blue = Starform-built; dashed = data store; gray = third-party.

§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 = Running snapshots 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 the snapshot_id PK 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) into usage_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 · raw snapshots + totals + events · Starbase Postgres
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 webhook dedup · stripe_events · Starbase Postgres
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 databases table's created_atdeleted_at window — by the same monthly cron (FR-076), which materializes the cents into usage_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.