🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs
Organizations

Seat-Based Billing

What counts as a seat, how the seat count is changed, and why the Stripe portal is locked down.

With seat-based billing, an organization's subscription is priced per person. The subscription row carries a seats quantity, the members of the organization consume it, and the database refuses to let usage exceed capacity. This page covers what consumes a seat, how the quantity is changed, and the Stripe configuration that keeps the app and Stripe in agreement.

Seat billing is Stripe only. A RevenueCat or App Store subscription has no server-adjustable quantity, so anything not Stripe-backed is refused with NOT_STRIPE_SUBSCRIPTION.

Seat-based billing only

This section applies only to projects generated with `--seat-based-billing`.

Requires ENABLE_SEAT_BASED_BILLING

Seat-based billing is off in a new project. Set `ENABLE_SEAT_BASED_BILLING=true` in both `.env.local` and `supabase/.env.local` to use this section.

A layer on top of two other layers

Everything on this page exists only in projects generated with seat-based billing, which itself requires multi-tenancy and Stripe. The backend's env validator enforces the chain: ENABLE_SEAT_BASED_BILLING is rejected unless ENABLE_MULTI_TENANCY and ENABLE_STRIPE are also true.

What counts as a seat

A used seat is an active member holding a seat-consuming role. That is the whole definition: the usage counter counts distinct users whose membership is active and whose role has counts_toward_seat = true.

PersonConsumes a seat?
Active member, any of the three seeded rolesYes, the owner included
Deactivated memberNo
Pending invitationNo
Member holding only a non-seat role you addedNo

Two consequences fall out of that table:

  • Deactivating a member frees their seat immediately. It is the supported way to stay under quota without removing someone; their role and history are kept, and reactivating them re-runs the seat check. See Members & Invites.
  • Invitations do not reserve seats. Several invitations can be issued against one free seat, and acceptance is first come, first served: the seat check runs again at acceptance, so a late accepter is refused with SEATS_LIMIT_REACHED even though their invite went out fine.

Sending the invitation is gated too, but the checks run in a deliberate order. An email that already has a pending invitation is refused with ACTIVE_INVITATION before the seat check, because re-inviting someone consumes no new seat; a full organization must not be the reason a duplicate invite sends an admin off to buy a seat for a person who already has one.

No subscription means no new members at all

The seat check looks for an active or trialing subscription first and raises NO_ACTIVE_SUBSCRIPTION if there is none. It is not "unlimited until you subscribe"; an organization without a live subscription cannot invite or reactivate anyone.

A free role is one column away

All three seeded roles consume seats because roles.counts_toward_seat defaults to true and the seed never overrides it. The column exists so you can add roles that do not, and the migration documents the pattern itself:

Seat-based billing only

This section applies only to projects generated with `--seat-based-billing`.

supabase/migrations/*_seat_based_billing.sql
-- ? Pattern for non-seat-consuming roles:
-- ? When creating a role that should not consume seats (e.g., "guest", "viewer", "external"):
-- ? INSERT INTO public.roles (slug, name, description, counts_toward_seat)
-- ? VALUES ('guest', 'Guest', 'Guest user with limited access', false);

Holders of such a role are invisible to the seat counter and skip the seat check entirely, at invitation and at reactivation. Flipping an existing role's counts_toward_seat to true is guarded the other way around: the change is refused with SEATS_LIMIT_REACHED if it would push any organization over its quota.

For a role that already exists, seeded or yours, the flip is one statement:

UPDATE public.roles
SET counts_toward_seat = false
WHERE slug = 'admin';

A statement-level trigger, trg_sync_seats_used_on_role_change in supabase/migrations/*_seat_based_billing.sql, resyncs every organization's seats_used in the same transaction, so freed or newly consumed seats are correct the moment the flip commits; the flip back to true is the guarded direction described above. Before your first deploy you can carry the same choice in supabase/db_seeds/permissions_and_roles.sql instead, by adding the counts_toward_seat column to the role's INSERT.

Changing the seat count

Seat changes live in Billing, behind the billing permission, so the owner and admins can make them and a plain member never sees the control; who holds which permission is on Authorization.

Pick a number. The stepper in the manage-seats sheet is bounded to [current usage, 999]; you cannot even request fewer seats than are in use. The server enforces the same bounds (INVALID_SEATS, SEATS_BELOW_USAGE), so freeing seats always means deactivating or removing members first.

Read the preview. As the number changes, the app asks Stripe for a priced preview and shows the exact money line before anything is committed. An increase is charged immediately; a decrease becomes a credit on the next invoice. The preview and the commit use the same proration rule, so the number you are shown is the number you get.

The manage seats sheet with the seat stepper and the priced preview

Confirm. The change is applied to the Stripe subscription in a mode where a declined card fails the request on the spot, with Stripe's own decline message, instead of quietly parking the subscription in past_due.

One invariant makes the whole flow safe to reason about: the Stripe webhook writes the database; the app never does. The seat-change function updates the subscription at Stripe and stops; subscriptions.seats only changes when the customer.subscription.updated event comes back, so the database always reflects what Stripe actually accepted. A hard CHECK constraint (seats_used <= seats) backstops the usage counter.

What members can see

Billing data has two audiences. The full subscription record is readable only by holders of the billing permission; members read a deliberately limited public view that carries the plan, status, period dates and trial dates, and nothing else: no seat counts, no price, no cancellation state.

MemberOwner / admin
Plan, status, renewal dateYesYes
Seats, seats used, priceNoYes
Manage seats, cancelNoYes

When an organization's subscription lapses, a member cannot fix it; they are routed to the access hub with a "contact your administrator" notice, while the owner and admins keep access to billing to recover the subscription. The hub itself is covered on Organizations.

Why the Stripe portal is restricted

The app is not the only surface that can edit a subscription; Stripe's hosted billing portal can too, and by default it honors whatever the account's default portal configuration allows. If that configuration includes quantity in its allowed subscription updates, any customer with portal access can edit their own seat count, bypassing the billing permission, SEATS_BELOW_USAGE, and every other guard above.

The project closes the gap with two dedicated portal configurations:

  • Restricted, with quantity removed: used for every customer-facing portal session, so seats are editable only in the app.
  • Confirm, with quantity present: used only for the app's own server-driven plan-change flow, which Stripe validates against the same allowed-updates list even though the server picks the quantity.

Both are provisioned once per Stripe account by scripts that are already part of setup. The setup steps live on Stripe and in Useful Commands; this page only explains what they protect.

Turning it on

Requires ENABLE_SEAT_BASED_BILLING

Seat-based billing is off in a new project. Set `ENABLE_SEAT_BASED_BILLING=true` in both `.env.local` and `supabase/.env.local` to use this section.

The flag lives in both environment files, and the backend additionally needs the confirm portal configuration provisioned by the script above:

.env.local
ENABLE_SEAT_BASED_BILLING=true
supabase/.env.local
ENABLE_SEAT_BASED_BILLING=true
STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID=bpc_...

The env validator ties the flags together: seat billing without ENABLE_MULTI_TENANCY and ENABLE_STRIPE refuses to boot, and the error names the missing flag. The full env matrix, and the boot failures a wrong combination produces, are on Troubleshooting.

What's next?

On this page