Skip to content

Running a platform on the Supabase Management API

The Management API is a complete platform substrate: projects, keys, config, usage, and lifecycle are all programmable. What the docs never assemble is the platform layer on top - the provisioning loop, the rate budget, the metering, the OAuth paths, the pause playbook. This guide is that assembly. Every number in it was measured against the live API on 2026-08-17/18 on throwaway projects; where something is gated or untested, it says so. To follow along: a Supabase org on a paid plan, a personal access token with Management API access, and throwaway projects you will delete when done. The placement doc decides where a tenant should live; this guide is the mechanism that puts it there.

The shape being built:

Your product(tenant-facing UI)Control planeprovisioning service + tenant->ref mapusage poller + rate-budgeted clientCredential-proxy gateway(optional; PAT stays server-side,scoped keys go out)api.supabase.com/v1projects / config / addons / analyticsdirect is fine at small scaleTenant poolone project per tenant(or shared tier - see placement doc)

Two integration shapes, differing on who owns the Supabase organization and the billing relationship:

  • You provision on their behalf - your org, your PAT, one project per tenant, users never see Supabase. White-label, you hold the billing relationship. The rest of this guide is this shape.
  • They bring their own backend - the user’s org, connected to your app through the Management API OAuth2 flow. You get a scoped token; they keep the direct billing relationship. Section 5 has the measured surface.

You can offer both; the project-claim flow is the (gated) bridge from the first to the second when a tenant outgrows you - measured: on a normal paid org the claim route answers 404 {"message":"Cannot POST /v1/oauth/authorize/project-claim"}, it does not exist for the credential class rather than merely being forbidden. Re-measured 2026-08-24 on a platform-plan org: the claim, oauth/apps, and projects/{ref}/transfer routes all still answer 404 - the BYO-backend bridge is off by default regardless of plan, so the two shapes are not separable in practice until the bridge is enabled for the account. For a time-boxed, network-restricted, role-scoped grant instead of a full project handover, the JIT database access surface exists and works on a platform-plan org: POST /database/jit/invite (email + role + expires_at + allowed_networks CIDRs) -> 200 with an invite_id, and DELETE /database/jit/invite/{id} -> 200 (measured 2026-08-24). The identical invite on a Pro org is rejected with a 500 (A/B measured 2026-08-25), so do not offer it there.

One call creates a project; the details are where the platforms get built:

  • POST /v1/projects with organization_slug, a strong generated db_pass, and a region. Create -> ACTIVE_HEALTHY measured 131-159 s on paid Micro, 12-13 s on a free org.
  • Smart region selection works on a normal paid org: region_selection: {type: "smartGroup", code: "apac"} was accepted (201) and the platform picked ap-northeast-2. Defer the city choice instead of pinning per tenant.
  • Healthy is not writable. Fresh projects refuse their first /auth/v1/admin/users write after reporting ACTIVE_HEALTHY: 5 of 5 with 500 unexpected_failure, accepting the second call one poll later (2026-08-04), and 2 of 2 with 500 "Database error checking email" for ~10 s after passing the per-service health poll (2026-08-03). Poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db, then retry the first write with backoff; do not treat it as a finding.
  • Fetch keys with GET /v1/projects/{ref}/api-keys?reveal=true and select by name OR type - new projects carry both legacy JWTs and the new sb_publishable_/sb_secret_ shapes.
  • Configure services with the config endpoints (auth, PostgREST, storage, realtime, functions + secrets). The OAuth server flips on with PATCH /v1/projects/{ref}/config/auth {oauth_server_enabled: true, oauth_server_authorization_path: ...} - measured 200.

Measured across three org classes:

Org classNanoFloorPause
Paid (Pro) orgrejected three ways: create 400 Minimum instance size on paid plans is Micro, addon PATCH 400 addon_variant: Invalid input, absent from available_addonsMicro, always-on400 Project is not free-tier
Free orgaccepted (201) - and there is NO compute addon catalogue at allshared/free computefull lifecycle: pause -> INACTIVE, restore wakes in 162-204 s, data API answers HTTP 540 Project paused while parked
Legacy free-era project inside a paid orgn/akeeps its paused state after the upgradeone-way door: once restored it cannot be re-paused - pause follows the org’s current plan, not the project’s lineage

Scale-to-zero economics are part of the Supabase for Platforms programme1. Measured 2026-08-24: an SfP organization is the platform plan, and Nano is the platform plan’s create default - the SfP-prescribed create (no desired_instance_size) provisions infra_compute_size: nano (224MB shared_buffers) and the project can pause. Nano is not a select or resize target on any plan. On a normal paid org there is no idle discount, which is the cost premise the tenant placement doc is built on.

Measured on a normal paid org, on a cheap scoped read:

  • Every response carries x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset. The limit read 120, decrementing 1:1 per call. Read the header; never infer the budget.
  • A deliberate burst tripped 429 {"message":"ThrottlerException: Too Many Requests"} at request 118 with retry-after: 60 and recovered after the window. The breach on this endpoint is the API’s own machine-readable JSON - but aggressive polling elsewhere has produced a non-JSON interstitial from the edge layer, so treat “non-JSON body” and “JSON 429” as the same back-off signal.
  • The budget is cumulative across a user’s PATs (measured 2026-08-18): alternating two tokens from the same user, each token’s x-ratelimit-remaining drops on the OTHER token’s calls - one shared counter per user per scope. PAT sharding does not multiply the budget.

At fleet scale the client is a queue with a token bucket, not a fetch loop.

The OAuth2 flow for the Management API: register an OAuth app, the user approves, you hold a scoped access/refresh pair.2 Measured surface on a normal paid org:

  • Unknown client_id at /v1/oauth/authorize -> 422 {"message":"Unrecognized client_id"}. Client validation fires before session validation, so the no-session redirect is only observable with a real client_id.
  • The token lifecycle, measured 2026-08-18 after a real consent: refresh grants return 24-hour access tokens AND a NEW refresh token every time - rotate or lose the grant. The token is org-scoped: it sees exactly the org approved at consent (1 org, its 3 projects), not the account’s other orgs. Revocation answers 204 and the refresh grant fails immediately with 404 - zero measurable lag.
  • The jwt-bearer token grant validates parameters before any gating (422 Required parameter: client_id).

A related but distinct surface: the project’s OWN OAuth 2.1 server (Supabase-as-IdP for third-party apps) is fully headless-automatable and its tokens carry client_id, which RLS can read. Measured 2026-08-18: auth.jwt() ->> 'client_id' in a policy showed a row to the matching client’s token and hid it from a second client of the same user - per-client permissions on a shared project, not just per-user.

Three layers, all measured - the full build (which also covers the non-tenant case: one consolidated org wanting per-project chargeback) is its own guide, Per-project cost attribution:

  1. The invoice already itemises usage per project ref (compute, disk, egress, storage, PITR, custom domains) with quantity and rate. The plan fee, monthly active users (MAU), function invocations, realtime, and discounts are org-level aggregates - they get an allocation policy, not data.
  2. The estimator between invoices: compute SKU from billing/addons, pg_database_size() and the Storage listing (exact to the byte), usage.api-counts (exact, ~1 min lag). No per-service egress bytes exist in the public per-project API.
  3. Real time at your own gateway: exact for anything transiting it, and scoped keys mean no god-mode PAT in scrapers (measured: 200 with 278 metric families for the allowed key, 403 deny-by-default elsewhere, ~1 s audit flush).

7. Move tenants when they outgrow the placement

Section titled “7. Move tenants when they outgrow the placement”

Cross-project trust (third-party JWKS) makes a tenant’s tokens verifiable on their new project, with two measured edges: refresh only works at the issuing project (400 refresh_token_not_found verbatim at the trusting one), and key rotation is a maintenance window, not an event (the trusting project’s cache does not re-resolve on any observed timeline). The build recipes: shared tier, promotion, consolidation; the decision framework is the placement doc, and what each operation costs in client-visible downtime is in platform operation costs.

  • Deleting a project has a tail - a delete returns ~2 s but the project lingers in lists briefly; batch deletes need canary batches.
  • A paused project answers HTTP 540 Project paused on the data API - a distinct, machine-readable state worth mapping to your own 503.
  • Restores are slow: a legacy project exceeded a 20-min wake bound before coming healthy; free-org restores measured 162-204 s. Wake ahead of the user, not on their click.
ClaimHow it was checked
Create -> healthy 131-159 s paid / 12-13 s freeMeasured 2026-08-17/18, n=5+ on paid
Smart region accepted on a paid org, picked ap-northeast-2Measured 2026-08-17
Nano rejected three ways on paid, accepted (201) on freeMeasured 2026-08-17/18
Legacy project: paused survives upgrade, cannot re-pauseMeasured 2026-08-18 - 400 Project is not free-tier
Rate-limit headers + JSON 429 with retry-after: 60Measured 2026-08-17 - burst to the boundary
OAuth authorize: 422 client validation first; claim flow 404Measured 2026-08-17
Project IdP: client_id claim usable in RLSMeasured 2026-08-18 - headless Proof Key for Code Exchange (PKCE) flow, two-client isolation
Metering: exact ground truth, exact 13/13 analytics at 61 s lag, no per-service egress in the APIMeasured 2026-08-17/18
Scoped-key gateway: 200/278 families, 403 deny-by-default, ~1 s auditMeasured 2026-08-18 against a live credential-proxy deployment
Refresh only at the issuing projectMeasured 2026-08-18 - 400 refresh_token_not_found verbatim

The practices the sections above imply, collected with the module or run each rests on. A practice with no module id is a design choice rather than a result: canary batch sizes, token-bucket parameters beyond 120 per minute per user, and the signal to wake a parked project on are all unmeasured.

PracticeRests on
Run the provisioning service under a dedicated automation user. The rate budget is per user across all its PATs (each token’s x-ratelimit-remaining drops on the other’s calls), so a separate user is a separate 120-per-minute bucket; and a PAT is unscoped (every scope endpoint 404s while /organizations and /profile return 200), so the user’s membership set is the blast radius.rate-limits L01b (2026-08-18); platform-facts F03
Before the first write on a fresh tenant, poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db per service, then retry the write anyway: 2 of 2 fresh projects passed the per-service poll and still failed the first admin/users call (2026-08-03); 5 of 5 refused the first write and took the second (2026-08-04).supabase-lab AGENTS.md, provisioning note (2026-08-03); placement reference, Verified row ‘Create -> healthy, and healthy is not writable’ (2026-08-04, n=5, bash run not in the lab repo)
Enumerate creatable regions per org with GET /v1/projects/available-regions?organization_slug=<slug> before offering a tenant a region picker: 200 with {recommendations, all: {smartGroup[], specific[]}}, 17 specific regions and 3 smart groups on a Team org; the bare call answers 400, and /regions and /projects/regions 404.platform-facts F04c (2026-08-20)
Persist the rotated refresh token before acknowledging the refresh, and map a 404 on the refresh grant to “revoked, re-consent required”: revocation answered 204 and the next refresh 404 on the first poll, 0 s lag.byo-oauth O01c, O01e (2026-08-18)
Budget a manual dashboard step per environment for OAuth app registration; there is no API for it. the project-claim route answers 404 on Pro (byo-oauth O02a) and oauth/apps 404 on the platform org (sfp-platforms S05, 2026-08-24), and the published spec’s 169 operations hold only two org-scoped writes, organization creation and the project-claim callback.byo-oauth RUNLOG, the manual drill; sfp-platforms S05 (2026-08-24); platform-facts F05
Do not offer JIT database access on a Pro org: POST /database/jit/invite answers 200 with an invite_id on a platform-plan org and the identical invite is rejected with 500 on Pro.sfp-platforms S15 (2026-08-25, A/B)
Mint tenant-facing or worker credentials with secret_jwt_template on POST /v1/projects/{ref}/api-keys and read them with ?reveal=true (the create response redacts api_key otherwise); the templated role and custom claim reach auth.jwt() on Pro and platform orgs. A fresh RPC answers PGRST202 until notify pgrst, 'reload schema'.sfp-platforms S14 (2026-08-25, both org classes)
Size a dedicated project at ci_small or above and enable PITR before promising a tenant read replicas: POST /read-replicas/setup answers 400 "Read replicas require a minimum size of small" on Pro and platform orgs, then waits on a completed physical backup; the chain closed on Pro after pitr_7 (setup 204). PITR enable on the platform org is its own refusal, 400 "Organization is not entitled to the selected PITR duration".sfp-platforms S07, S07e (2026-08-25)
Grow disk to 6 GB or more in one step on a fresh gp3 volume (2 GB / 3000 IOPS / 125 MiB/s; the gp3 IOPS floor makes 2 -> 4 GB impossible). The grow is async (201 with an empty body); 8 GB confirmed landed on a later poll.sfp-platforms S08 (2026-08-24/25)
Do not promise tenants a backup time of day: GET /database/backups/schedule answers a structured 402 entitlement_required (feature: backup.schedule) on Pro and platform orgs; the spec text says Enterprise.sfp-platforms S11 (2026-08-24/25)
Before restoring a paused free-era project inside a paid org, decide whether it should instead be deleted or moved to a Free org: once woken it joins the always-on floor (400 Project is not free-tier on the next pause), and the wake exceeded a 20-minute bound.instance-sizing I03 (2026-08-18)
After DELETE /v1/projects/{ref}, poll GET /v1/projects until the ref is absent before reusing the name or counting against a quota; the delete returns in about 2 s and the project lingers in lists.section 8 (lingers in lists); placement reference, Verified row ‘Create -> healthy, and healthy is not writable’ (2026-08-04, n=5, delete about 2 s, bash run not in the lab repo)

The probes re-run on throwaway projects you create and delete; the calls are the ones the sections make. Sections 3 and 8 additionally need a free-tier project for the free-org rows.

  • Sizing (3) - create a project and re-take the three Nano rejections (create, addon PATCH, available_addons); create with region_selection: {type: "smartGroup", code: "apac"} and read which region lands.
  • Rate budget (4) - burst a cheap scoped read until the 429, reading the budget from the x-ratelimit-* headers; alternate two of your PATs to re-check the shared counter.
  • OAuth (5) - authorize with an unknown client_id, then run the token lifecycle after a real consent: refresh, revoke, re-refresh.
  • Metering (6) - insert a known payload and read pg_database_size(); send a counted batch of REST GETs and watch usage.api-counts catch up; scrape per-project metrics through your gateway with a scoped key.
  • Deletions and pauses (8) - delete the project and re-poll the list for the tail; pause a free-tier project and read the data API for the HTTP 540 Project paused.

The measurement harness is public at supabase-lab - one RUNLOG.md per experiment under experiments/ - but its destructive probes run against the author’s orgs and secrets, so a reader re-runs the API calls above and reads the run logs in the repo.

SectionExperiment in supabase-lab, RUNLOG pinned to commit c1894cc
1, 5 - the claim flow and the OAuth surfacebyo-oauth
1, 2, 3 - the platform-plan A/B: JIT access, templated keys, the replica and disk floors, the backup schedulesfp-platforms
2, 4 - the unscoped PAT, the region catalogue, the read-only membership surfaceplatform-facts
3, 8 - sizing floors, the legacy door, pause and restoreinstance-sizing
4 - the rate budgetrate-limits
6 - metering and the scoped-key gatewayusage-metering
7 - cross-project trustcross-project-auth
If you are askingGo to
Where should a tenant live, and what does it cost?Tenant placement
What does each operation cost in downtime?Platform operation costs
Build the shared tierShared tenancy guide
Move a tenant to its own projectTenant promotion
Merge many projects into oneTenant consolidation
  1. Supabase for Platforms - the white-label programme; scale-to-zero is the platform plan’s Nano create default (measured 2026-08-24), not a gated catalogue variant.

  2. Build a Supabase OAuth integration - the Management API OAuth2 flow (authorize, token exchange, refresh, revoke).