Skip to content

Per-project cost attribution on Supabase

Two different situations need the same answer - “what did this project cost?” - and neither gets it from a dashboard:

  • A platform billing its users for per-tenant backends (one Supabase project per tenant).
  • One company that consolidated many projects into a single org for a unified bill and lost per-project visibility - internal chargeback, showback, or a cost review before a commit decision.

The first situation presumes the one-project-per-tenant answer to the Tenant placement decision; this guide prices that answer.

There is no org-scoped billing read on the Management API. What exists is enough to build attribution yourself, in three layers. You need a Pro org, a PAT, and the projects you want to attribute. Every number below was measured against the live API on 2026-08-17/18 on a Pro org; the invoice structure was read off a real Pro invoice.

Layer 1 - the invoice is the settlement layer, and it already itemises per project ref

Section titled “Layer 1 - the invoice is the settlement layer, and it already itemises per project ref”

Every usage-based line on the invoice breaks out per 20-char project ref with quantity and rate: compute hours, branching compute, disk GB-hrs, point-in-time recovery, cached and uncached egress, storage GB-hrs, custom domains. The per-project usage data is on the invoice itself - anonymously (refs, not names), but present.

Two properties of that itemisation matter before you build on it:

  • The per-ref numbers are gross at list price. Discounts and allowances apply ABOVE the project line, never to it (the invoice shows this literally: Discount ($50.00 across 74 prices), unit allowances of -2,000,000 invocations, -250 GB egress). Net cost per project requires an allocation rule Supabase does not supply - and with a credits pack, the pooled share can be the majority of the spend, not a rounding error.
  • A monthly PDF is not a dataset. It answers no trend, what-if, or idle-detection question, and it exists only after the month closes. That gap - queryable hourly time series per ref - is what layer 2 builds.

The plan fee, monthly active users (MAU), function invocations, realtime messages and peak connections, and discounts do NOT itemise per project. These are org-level aggregates with no project dimension, and no amount of tooling changes that - they get an allocation policy (equal split, weighted by another signal, or eaten by the platform), not data.

So the monthly reconciliation is: map ref -> owner, then apply the policy to the pooled lines.

A control-plane poller builds the same picture hourly from public endpoints. Measured behaviour of each signal:

  • Compute cost per project: GET /v1/projects/{ref}/billing/addons returns the selected compute SKU. On Pro nothing suspends (measured: pause is refused with 400 Project is not free-tier); Team reads project_pausing: false on the entitlements endpoint (2026-08-03) and was not asked to pause, so compute-hours are provisioned wall time on both. On a platform-plan org pausing is enforced (200 -> INACTIVE; sfp-platforms S06, 2026-08-24/25) and on a Free org the lifecycle is live (data API HTTP 540 Project paused while parked), so read status on every sweep and count hours only while the project is not INACTIVE. SKU rate x hours at the invoice’s own rates - Micro measured at $0.01344/hr there.
  • Database size, exact: pg_database_size() over POST /v1/projects/{ref}/database/query. One trap: the deltas compress - a repeat()-generated 8 MB payload moved the measured size by ~180 KB because TOAST eats compressible text. Use random payloads when validating your own pipeline.
  • Storage bytes, exact: the Storage object listing matched an uploaded 262144-byte object to the byte.
  • Request volumes per service, exact at minute-scale lag: GET /v1/projects/{ref}/analytics/endpoints/usage.api-counts?interval=15min returned 13 for exactly 13 REST requests sent, visible after 61 s. Billing-rollup grade; not real-time-enforcement grade.
  • No per-service egress bytes. The per-project Prometheus endpoint (300+ metric families via a plain PAT) is infrastructure-grade - node_, pg_, pgbouncer_ families with host-level network counters only. Per-project egress per service exists on the invoice per ref and nowhere in the public per-project API. If egress allocation matters between invoices, it has to come from layer 3.

The poller’s budget matters at fleet scale: the Management API answers ~120 requests/min per user per scope with x-ratelimit-remaining on every response (measured), so pace by the header rather than a guessed constant.

Layer 3 - real time: meter at your own gateway

Section titled “Layer 3 - real time: meter at your own gateway”

If requests to tenant projects transit your own ingress (a proxy, a gateway, an edge worker), metering there is exact and immediate. The credential story improves at the same time: measured end-to-end against a live credential-proxy gateway (PAT stored server-side, scoped keys minted per project), a key allowed metrics:read on exactly one project scraped its metrics (200, 278 families) while the same key got 403 on an unclassified path and on any other project, with every proxied call visible in an audit feed within ~1 s. Scoped, revocable keys beat handing a god-mode PAT to every scraper.

Direct Postgres connections bypass any gateway - they are covered by layers 1 and 2 instead.

Deliberately boring:

  1. A tenant/owner -> project_ref map in your control-plane database, written at provision time.
  2. An hourly row per project: compute SKU, status, database size, storage bytes, request-count deltas. (At ~170 projects this is a few hundred API calls per sweep - well inside the rate budget if you read x-ratelimit-remaining.)
  3. A monthly join of that table against the invoice’s per-ref lines.

If the join disagrees, the invoice wins - it is the settlement document. The estimator exists to see the month before it closes, not to replace it.

Every mechanism in this guide runs as a tested module in the public supabase-lab repo1 (experiments/usage-metering/), each gated by an acceptance probe against the live platform:

ModuleWhat it provesMeasured
M03The estimator, read-only against a live org: enumerate -> SKU -> cost table3 projects at $9.81/mo each, $29.43 org monthly compute - matching the invoice’s own rate card
M04Per-key metering at a credential-proxy gateway is exact: one scoped key per tenant = a per-tenant usage ledger7 proxied calls -> exactly 7 events, 5 -> 5, correct project ref and status on every event
M05One project can hold the tenant map + rollups including itself (self-inclusion), and per-tenant attribution inside a shared schema is exact from SQL4/4 rows inserted via the store’s own PostgREST; t-a 100 rows / 100,400 bytes vs t-b 25 / 25,100 via pg_column_size
M06The idempotent rollup property the billing literature treats as non-negotiableRe-flush identical; a late event into a closed window moves its total by exactly its quantity; duplicate idempotency keys rejected; other windows untouched
M07The invoice parses into a dataset and reconciles against the live org91 per-ref lines, 32 refs, 3 matched by name, 29 deleted since invoice; standing projects billed 592/600 h (98.7% of the window)

The practices the three layers and the module table imply, collected with the module each rests on. A practice with no module id is a design choice rather than a result: the allocation policy for pooled lines and the sweep cadence are choices, and nothing measures which cadence billing precision needs.

PracticeRests on
Never delete the ref -> owner row when the project is deleted; keep it indefinitely. The invoice arrives after the month closes and itemises refs that no longer exist: on the reconciled invoice 29 of 32 refs had been deleted since, and only 3 could be matched by name.usage-metering M07
Make the hourly rollup idempotent: key each row by (ref, hour) and upsert, give gateway events an idempotency key and reject duplicates, and recompute a closed window when a late event lands - its total moves by exactly the event’s quantity and other windows stay untouched.usage-metering M06
Read status on every sweep and count compute hours only while the project is not INACTIVE. Pro refuses a pause (400 Project is not free-tier, measured); Team reads project_pausing: false on the entitlements endpoint (2026-08-03) and was not asked to pause; a platform-plan org enforces it (200 -> INACTIVE); a Free org pauses and restores (data API HTTP 540 Project paused). Even on Pro the hours are not the full window: standing projects on the reconciled invoice billed 592 of 600 h (98.7 %).sfp-platforms S06 (2026-08-24/25); instance-sizing I04; usage-metering M07
Run the poller under a dedicated automation user: the 120-per-minute budget is per user across PATs (each token’s x-ratelimit-remaining drops on the other’s calls), so sharing a human’s user starves both. Treat a non-JSON body as a 429 alongside the JSON ThrottlerException with retry-after: 60.rate-limits L01, L01b (2026-08-17/18)
Expose the metering schema to PostgREST or keep the store in public: PostgREST serves only the configured db-schemas (default public), so a custom schema 404s until PATCH /v1/projects/{ref}/postgrest sets db_schema to include it. Never set db_schema: "" - that wedges PostgREST (503 PGRST002 within 6-8 s).usage-metering M05; supabase-lab AGENTS.md, http-tier-lockdown (2026-08-02)
For tenants on a shared project, attribute bytes per tenant from SQL: sum(pg_column_size(t.*)) grouped by tenant_id, hourly (measured 100 rows / 100,400 bytes against 25 / 25,100). The invoice and billing/addons are per project; inside one project this is the only measured attribution signal.usage-metering M05
For metrics scrapers without a gateway, use a PAT from a dedicated automation user whose only membership is the org being metered: the per-project Prometheus endpoint accepts a plain PAT (200, 326-328 families), and a PAT cannot be scoped (every scope endpoint 404s), so membership is the only narrowing available.usage-metering M01c; platform-facts F03
When validating usage.api-counts, anchor the read to the last request sent and send Prefer: count=exact: the first run’s 10 of 12 was warm-up requests confounding the count, and the corrected run read 13 of 13 after 61 s.usage-metering M01b (2026-08-17) and the 2026-08-18 re-run

Validate the layer-2 estimator against your own org using only the endpoints this doc measured, one pass per project ref:

  • Compute SKU: GET /v1/projects/{ref}/billing/addons returns the selected compute SKU; multiply by the invoice’s own rates for that SKU’s hours.
  • Database size: pg_database_size() over POST /v1/projects/{ref}/database/query. To confirm the deltas are real, grow a table with random bytes, not repeat()-generated, because TOAST compresses the latter.
  • Storage bytes: a Storage object listing should match an uploaded object to the byte.
  • Request volume: GET /v1/projects/{ref}/analytics/endpoints/usage.api-counts?interval=15min should return exactly the requests you sent, visible after 61 s.
  • Pace the sweep by x-ratelimit-remaining on every response rather than a fixed interval.

M03 in the module table is the read-only reference implementation of this pass against a live org.

Two honest edges from the runs: PostgREST exposes only the configured db-schemas (default public) - a custom metering schema 404s until configured; and the same ref can appear in multiple invoice sections (compute and branching compute), so a production parser tracks section headers.

  • Attribution of pooled org-level lines (plan fee, MAU, functions, realtime, discounts) - a policy decision, never data.
  • History before you start polling - the estimator sees forward from when it is built; the invoice archive is the only backwards view.
  • Enforcement - no spend caps or exhaustion webhooks exist to push against; detecting a runaway project and acting on it is your own poll-and-act loop on layer 2/3 signals.
ClaimHow it was checked
Invoice itemises usage lines per project ref; plan/MAU/functions/realtime/discounts do not itemiseRead off a real Pro invoice, 2026-08
Compute SKU per project via billing/addons; pause refused on ProMeasured 2026-08-17/18 - 400 Project is not free-tier; on a platform-plan org pause is enforced (200 -> INACTIVE, sfp-platforms S06, 2026-08-24/25)
Micro rate $0.01344/hrRead off the invoice’s compute line
pg_database_size + Storage listing exactMeasured 2026-08-17 - byte-exact storage; TOAST compression caveat on database size
usage.api-counts exact (13/13) at 61 s lagMeasured 2026-08-17/18, reproduced after accounting fix
No per-service egress bytes in the per-project APIMetrics endpoint enumerated 2026-08-18 - node_/pg_/pgbouncer_ families only
Scoped-key gateway metering: 200/278 families, 403 deny-by-default, ~1 s auditMeasured 2026-08-18 against a live credential-proxy deployment
~120 req/min per user per scope, headers on every responseMeasured 2026-08-17 - burst to the JSON 429 boundary
If you are askingGo to
Should tenants share an instance or get their own projects?Tenant placement
What does each Management API operation cost in downtime?Platform operation costs
The full platform build (provision, size, rate budget, OAuth)Running a platform on the Management API
  1. supabase-lab - the measurement harness; experiments/usage-metering/ carries M01-M07 with runbooks.