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. Unless a row says otherwise, numbers were measured against the live API on 2026-08-17/18 on a Pro org; later runs and other plans are dated where they appear; the invoice structure was read off a Pro-plan 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.
- 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. - 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. Layer 2 builds the missing part: a queryable hourly time series per ref.
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, so they need an allocation policy (equal split, weighted by another signal, or eaten by the platform); no tooling can recover per-project data for them.
The monthly reconciliation maps ref -> owner, then applies the policy to the pooled lines.
Layer 2 - between invoices, the estimator
Section titled “Layer 2 - between invoices, the estimator”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/addonsreturns the selected compute SKU, and cost is SKU rate x hours at the invoice’s own rates (Micro measured at $0.01344/hr there). On Pro nothing suspends (pause is refused with400 Project is not free-tier, measured); Team readsproject_pausing: falseon the entitlements endpoint (2026-08-03) and was not asked to pause, so compute-hours are provisioned wall time on both. Aplatform-plan org enforces pausing (200->INACTIVE; sfp-platforms S06, 2026-08-24/25). On a Free org the data API answersHTTP 540 Project pausedimmediately after the pause, and the hostname stops resolving once teardown finishes (measured September 2026). Readstatuson every sweep and count hours only while the project is notINACTIVE. Storage is accounted separately from compute, so do not model storage as zero because compute has stopped. This corpus has not measured storage accounting across the compute states. - Database size, exact:
pg_database_size()overPOST /v1/projects/{ref}/database/query. The deltas compress: arepeat()-generated 8 MB payload moved the measured size by ~180 KB because TOAST compresses repetitive 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=15minreturned 13 for exactly 13 REST requests sent, visible after 61 s. That lag is fine for billing rollups and too slow for real-time enforcement. - No per-service egress bytes. The per-project
Prometheus endpoint (326-328 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.
At fleet scale the poller is bound by the Management API budget of
~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, and the
gateway can also narrow the credentials. 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 through the gateway (200, 278 families) while the same key got 403 on an
unclassified path and on any other project, and every proxied call was
visible in an audit feed within ~1 s. Each scraper gets a scoped, revocable
key instead of the account-wide PAT.
Direct Postgres connections bypass any gateway - they are covered by layers 1 and 2 instead.
The build
Section titled “The build”- A
tenant/owner -> project_refmap in your control-plane database, written at provision time. - An hourly row per project: compute SKU, status, database size, storage
bytes, request-count deltas. At a fleet of a couple of hundred projects
this is a few hundred API calls per sweep, inside the rate budget if you
pace by
x-ratelimit-remaining. - A monthly join of that table against the invoice’s per-ref lines.
If the join disagrees, the invoice wins because it is the settlement document; the estimator shows the month before it closes.
One org, many business units
Section titled “One org, many business units”The same machinery answers a second question: several business units share
one organization and one bill, and each unit needs its own cost. The
platform records no attribution - the project object carries no creator,
tag, label or metadata field, on the organization listing or the project
detail (BA02) - so the owner map has
to be written by whoever creates the project. Measured on one
platform-plan organization (ap-southeast-1) on 2026-10-02, two runs:
- The create response is the write path.
POST /v1/projectsanswers201with the ref in the body in 5.5-6.0 s, so the service that creates a project recordsref -> unitin the same call. Writing a pending row before the call, and completing it on the response, keeps a crash between the two recoverable (design choice). - A name can change after create.
PATCH /v1/projects/{ref}renames a project (200), so a unit prefix in the name can drift from the map. - The sweep reads the organization listing.
GET /v1/organizations/{slug}/projectslisted the new ref at the first poll after the201(5.8 s and 6.6 s after the create call started, two runs; the create call itself took 5.5-6.0 s) and returns a page limit of 100 (pagination.count,limit,offset). Diffing it against the map puts any project created outside the write path into anunattributedbucket instead of losing it. - Branches are not in that listing. A preview branch gets its own ref,
was absent from the organization listing for the 120 s it was polled, and
GET /v1/projects/{branch_ref}answers404. Of the reads probed, the parent link appears only on the parent’sGET /v1/projects/{ref}/branchesentry (parent_project_ref);GET /v1/branches/{id}returns connection config with no parent field, so the sweep walks each parent’s branches and assigns the parent’s unit (BA03). - Deleted refs had left both listings by the first poll, 2.4-3.4 s after the delete (one run). Together with the Pro-plan invoice reconciled in M07 (29 of its 32 refs had been deleted by reconciliation time), that is why the map keeps every row. Giving rows a validity period, so that moving a project between units does not rewrite past months, is a design choice.
- This
platform-plan org is entitled to project-scoped roles.project_scoped_rolesreadstrue,security.member_roleslists Owner, Administrator, Developer, Read-only and None, andsecurity.audit_logs_daysis 366;api.members.rolesreadsfalse, so role assignment is not available to this org over the Management API (BA01). Per the Access Control guide, only Owner and Administrator can create projects, a project-scoped member cannot view, access or see other projects in the dashboard, and an organization-level role covers current and future projects.1 The guide lists project-scoped roles under Team and Enterprise only; thisplatform-plan org reads the entitlement astrue. The same behaviour over the Management API has not been measured. - The audit log names the creating token, in the dashboard only. The
Platform Audit Logs guide lists the actor and token type on each entry,
and no export or log drain.2 The open-source dashboard
reads the log from
/platform/organizations/{slug}/audit, and its response type declarestoken_type,token_hash,token_alias(the token’s name as shown in the dashboard) andoauth_app_id/oauth_app_namefor each actor.3 A PAT is refused on that route with401 JWT could not be decodedwhile the same PAT answers200on/v1(production Team organization, BA06). Naming each unit’s token, or giving each unit its own OAuth app, lets a person attribute a leftoverunattributedref from the audit entry; it cannot feed the sweep.
On-demand creation and the sweep run under one automation user whose budget
read 120 per window on this org (BA01) and is
cumulative across that user’s PATs (L01b, below); the counter is
per user per scope, so how creates, sweeps and per-parent branch reads share
it was not measured. Because the measured counter is per user, one
automation user per unit should give each its own budget; two users were
not measured. The monthly check is the same join as above, extended:
unit totals plus unattributed plus the allocated pooled lines equal the
invoice total. Whether a platform-plan invoice itemises per ref the way
the Pro invoice in layer 1 does has not been measured yet.
The reference implementation
Section titled “The reference implementation”Every mechanism in this guide runs as a tested module in the public
supabase-lab repo4
(experiments/usage-metering/), each gated by an acceptance probe against
the live platform:
| Module | What it proves | Measured |
|---|---|---|
| M03 | The estimator, read-only against a live org: enumerate -> SKU -> cost table | 3 projects at $9.81/mo each, $29.43 org monthly compute - matching the invoice’s own rate card |
| M04 | Per-key metering at a credential-proxy gateway is exact: one scoped key per tenant = a per-tenant usage ledger | 7 proxied calls -> exactly 7 events, 5 -> 5, correct project ref and status on every event |
| M05 | One project can hold the tenant map + rollups including itself (self-inclusion), and per-tenant attribution inside a shared schema is exact from SQL | 4/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 |
| M06 | The rollup is idempotent under re-flush and late events | Re-flush identical; a late event into a closed window moves its total by exactly its quantity; duplicate idempotency keys rejected; other windows untouched |
| M07 | The invoice parses into a dataset and reconciles against the live org | 91 per-ref lines, 32 refs, 3 matched by name, 29 deleted since invoice; standing projects billed 592/600 h (98.7% of the window) |
What to do about it
Section titled “What to do about it”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.
| Practice | Evidence | Module |
|---|---|---|
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; recompute a closed window when a late event lands. | Gateway events need their own idempotency key so duplicates are rejected. The recomputed total moves by exactly the late event’s quantity and other windows stay untouched. | usage-metering M06 |
Read status on every sweep and count compute hours only outside 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. | rate-limits L01, L01b (2026-08-17/18) |
| Treat a non-JSON body as a 429. | The measured breach on a scoped read is a JSON ThrottlerException with retry-after: 60; the non-JSON body is a different response, the edge layer’s HTML page, seen under aggressive polling of other endpoints. | rate-limits L01, L01b (2026-08-17/18) |
Expose the metering schema to PostgREST with PATCH /v1/projects/{ref}/postgrest, or keep the store in public; never set db_schema: "". | PostgREST serves only the configured db-schemas (default public), so a request for a custom schema is refused with 406 PGRST106 until db_schema includes it. The empty value wedges PostgREST (503 PGRST002 within 6-8 s). | usage-metering M05; supabase-lab AGENTS.md, http-tier-lockdown (2026-08-02) |
Sweep GET /v1/organizations/{slug}/projects page by page and diff it against the map. | Refs missing from the map go to an unattributed bucket (design choice). The new ref was listed at the first poll after the 201 (5.8 s and 6.6 s from the start of the create call, two runs); the listing returns a page limit of 100. | bu-attribution BA02 (2026-10-02) |
Attribute branch refs through the parent’s GET /v1/projects/{ref}/branches entry. | Branches were absent from the organization listing (polled 120 s) and GET /v1/projects/{branch_ref} answers 404; of the reads probed, only the parent’s branch list carries parent_project_ref. | bu-attribution BA03 (2026-10-02) |
| Name each unit’s token, or give each unit its own OAuth app, so the dashboard audit entry identifies it. | The published audit response type declares token_alias, token_hash and oauth_app_name per actor; a PAT gets 401 on the dashboard’s audit route, so this is a manual check, not a feed. | bu-attribution BA06 (2026-10-02) |
Record ref -> unit from the create response, never from the project name. | PATCH /v1/projects/{ref} renamed a project (200), and the project object has no creator or tag field. | bu-attribution BA02 (2026-10-02) |
For tenants on a shared project, attribute bytes per tenant from SQL: sum(pg_column_size(t.*)) grouped by tenant_id, hourly. | The measured readings were 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 |
Verification
Section titled “Verification”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/addonsreturns the selected compute SKU; multiply by the invoice’s own rates for that SKU’s hours. - Database size:
pg_database_size()overPOST /v1/projects/{ref}/database/query. To confirm the deltas are real, grow a table with random bytes, notrepeat()-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=15minshould return exactly the requests you sent, visible after 61 s. - Pace the sweep by
x-ratelimit-remainingon 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.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”PostgREST exposes only the configured db-schemas (default public), so
a request for a custom metering schema gets 406 PGRST106 until
configured. The same ref can appear in multiple invoice sections (compute
and branching compute), so a production parser tracks section headers.
What this does not give you
Section titled “What this does not give you”- 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.
Verified / tested
Section titled “Verified / tested”| Claim | How it was checked |
|---|---|
| Invoice itemises usage lines per project ref; plan/MAU/functions/realtime/discounts do not itemise | Read off a Pro-plan invoice, 2026-08 |
Compute SKU per project via billing/addons; pause refused on Pro | Measured 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/hr | Read off the invoice’s compute line |
pg_database_size + Storage listing exact | Measured 2026-08-17 - byte-exact storage; TOAST compression caveat on database size |
usage.api-counts exact (13/13) at 61 s lag | Measured 2026-08-17/18, reproduced after accounting fix |
| No per-service egress bytes in the per-project API | Metrics endpoint enumerated 2026-08-18 - node_/pg_/pgbouncer_ families only |
| Scoped-key gateway metering: 200/278 families, 403 deny-by-default, ~1 s audit | Measured 2026-08-18 against a live credential-proxy deployment |
| ~120 req/min per user per scope, headers on every response | Measured 2026-08-17 - burst to the JSON 429 boundary |
| Create returns the ref in the 201 body in 5.5-6.0 s; the organization listing shows it at the first poll after (5.8 / 6.6 s from the create call), page limit 100 | Measured 2026-10-02, two runs, on a platform-plan org (BA02) |
| No creator/tag/label/metadata field on the project; names mutable; deleted refs gone by the first poll (2.4-3.4 s) | Measured 2026-10-02 (BA02) |
| Branch refs absent from the organization listing (polled 120 s); of the reads probed, parent link only on the parent’s branch list | Measured 2026-10-02 (BA03) |
Platform-plan org entitlements: project-scoped roles true, audit log 366 days, api.members.roles false | Entitlements read 2026-10-02 (BA01) |
| A project-scoped member cannot create projects or see other units’ projects | Documented for the dashboard (Access Control guide); not measured on the Management API |
A platform-plan invoice itemises usage per ref | Not measured yet |
| A PAT cannot read the organization audit log | Measured 2026-10-02 on a production Team org: 401 on the dashboard route, 200 on /v1 (BA06) |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| BA01 | bu-attribution | ba01-platform-facts.ts | none published |
| BA02 | bu-attribution | ba02-create-sweep-lifecycle.ts | none published |
| BA03 | bu-attribution | ba03-branch-refs.ts | none published |
| BA06 | bu-attribution | ba06-audit-log-access.ts | none published |
| F03 | platform-facts | f03-pat-scope.ts | none published |
| I04 | instance-sizing | i04-free-org.ts | none published |
| L01 | rate-limits | r01-rate-limit-surface.ts | none published |
| L01b | rate-limits | r01-rate-limit-surface.ts | none published |
| M01b | usage-metering | m01-diy-usage-metering.ts | none published |
| M01c | usage-metering | m01-diy-usage-metering.ts | none published |
| M03 | usage-metering | m03-attribution-estimator.ts | none published |
| M04 | usage-metering | m04-gateway-exact-metering.ts | none published |
| M05 | usage-metering | m05-control-plane-store.ts | none published |
| M06 | usage-metering | m06-idempotent-rollup.ts | none published |
| M07 | usage-metering | m07-invoice-reconciliation.ts | none published |
| S06 | sfp-platforms | s06-platform-entitlements.ts | none published |
Related
Section titled “Related”| If you are asking | Go 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 |
References
Section titled “References”-
Supabase, “Access Control,” Supabase Docs. https://supabase.com/docs/guides/platform/access-control ↩
-
Supabase, “Platform Audit Logs,” Supabase Docs. https://supabase.com/docs/guides/security/platform-audit-logs ↩
-
Supabase, “packages/api-types/types/platform.d.ts” (
AuditLogsResponse_Output), supabase/supabase, commit 6143441. https://raw.githubusercontent.com/supabase/supabase/614344149399/packages/api-types/types/platform.d.ts ↩ -
supabase-lab - the measurement harness;
experiments/usage-metering/carries M01-M07 with runbooks. ↩