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. 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.

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, 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 with 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, so compute-hours are provisioned wall time on both. A platform-plan org enforces pausing (200 -> INACTIVE; sfp-platforms S06, 2026-08-24/25). On a Free org the data API answers HTTP 540 Project paused immediately after the pause, and the hostname stops resolving once teardown finishes (measured September 2026). Read status on every sweep and count hours only while the project is not INACTIVE. 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() over POST /v1/projects/{ref}/database/query. The deltas compress: a repeat()-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=15min returned 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.

  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 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.
  3. 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.

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/projects answers 201 with the ref in the body in 5.5-6.0 s, so the service that creates a project records ref -> unit in 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}/projects listed the new ref at the first poll after the 201 (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 an unattributed bucket 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} answers 404. Of the reads probed, the parent link appears only on the parent’s GET /v1/projects/{ref}/branches entry (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_roles reads true, security.member_roles lists Owner, Administrator, Developer, Read-only and None, and security.audit_logs_days is 366; api.members.roles reads false, 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; this platform-plan org reads the entitlement as true. 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 declares token_type, token_hash, token_alias (the token’s name as shown in the dashboard) and oauth_app_id / oauth_app_name for each actor.3 A PAT is refused on that route with 401 JWT could not be decoded while the same PAT answers 200 on /v1 (production Team organization, BA06). Naming each unit’s token, or giving each unit its own OAuth app, lets a person attribute a leftover unattributed ref 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.

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:

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 rollup is idempotent under re-flush and late eventsRe-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)

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.

PracticeEvidenceModule
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

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.

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.

  • 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 Pro-plan 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
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 100Measured 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 listMeasured 2026-10-02 (BA03)
Platform-plan org entitlements: project-scoped roles true, audit log 366 days, api.members.roles falseEntitlements read 2026-10-02 (BA01)
A project-scoped member cannot create projects or see other units’ projectsDocumented for the dashboard (Access Control guide); not measured on the Management API
A platform-plan invoice itemises usage per refNot measured yet
A PAT cannot read the organization audit logMeasured 2026-10-02 on a production Team org: 401 on the dashboard route, 200 on /v1 (BA06)
ModuleExperimentTestArtifact
BA01bu-attributionba01-platform-facts.tsnone published
BA02bu-attributionba02-create-sweep-lifecycle.tsnone published
BA03bu-attributionba03-branch-refs.tsnone published
BA06bu-attributionba06-audit-log-access.tsnone published
F03platform-factsf03-pat-scope.tsnone published
I04instance-sizingi04-free-org.tsnone published
L01rate-limitsr01-rate-limit-surface.tsnone published
L01brate-limitsr01-rate-limit-surface.tsnone published
M01busage-meteringm01-diy-usage-metering.tsnone published
M01cusage-meteringm01-diy-usage-metering.tsnone published
M03usage-meteringm03-attribution-estimator.tsnone published
M04usage-meteringm04-gateway-exact-metering.tsnone published
M05usage-meteringm05-control-plane-store.tsnone published
M06usage-meteringm06-idempotent-rollup.tsnone published
M07usage-meteringm07-invoice-reconciliation.tsnone published
S06sfp-platformss06-platform-entitlements.tsnone published
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, “Access Control,” Supabase Docs. https://supabase.com/docs/guides/platform/access-control ↩

  2. Supabase, “Platform Audit Logs,” Supabase Docs. https://supabase.com/docs/guides/security/platform-audit-logs ↩

  3. 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 ↩

  4. supabase-lab - the measurement harness; experiments/usage-metering/ carries M01-M07 with runbooks. ↩