Billing
Stripe customers per tenant, Checkout and the customer portal, seat sync from membership events, and Stripe webhook handling that links customers and refreshes sessions.
The billing block connects each tenant to a Stripe customer. It opens
Checkout and the customer portal, keeps a per-seat subscription's quantity in
step with the tenant's members, and handles the Stripe events that change a
plan. Subscriptions are read from the
Stripe Sync Engine tables that entitlements
uses, so the block never calls Stripe to find out what a tenant pays for.
better-supabase sql add billing entitlements # adds tenant and access as well
pnpm add stripe # optional: or pass your own clientbilling_customers maps organization_id to stripe_customer_id, with a
foreign key to the tenant table (billing_customers_tenant_fkey, on delete cascade), so a deleted tenant takes its row along. The key references the
organizations module's table when it is
installed; set options.tenantKey to "schema.table.column" for your own
tenant table, or to false for none. Existing rows without a tenant leave
the key unvalidated, with a warning. With
billing installed, the entitlements module reads it as its customer
source, so you don't set entitlements.customer. Members with billing.read
can read their tenant's row and billing_status; the default admin role
has billing.*. The other functions are granted to service_role only.
Setup
import { createBilling, sqlTransport } from "better-supabase/blocks/billing";
export const billing = createBilling({
stripe: { secretKey: env.STRIPE_SECRET_KEY },
transport: sqlTransport(postgres.admin),
events: bs.events, // billing.* block events
seatPrice: env.STRIPE_SEAT_PRICE,
sessions: {
sql: postgres.admin,
invalidate: (id) => bs.invalidateSession(id),
},
});stripe takes a secret key, which loads the optional stripe package with
its fetch HTTP client so it runs on every runtime, a Stripe client you
created, or a function that returns a client. The function runs on every
Stripe call, so it can create the client lazily, read a rotated key, or pick
a per-request client (a Stripe Connect account); cache inside it when the
client is the same each time:
let client: Stripe | undefined;
const billing = createBilling({
stripe: () => (client ??= new Stripe(env.STRIPE_SECRET_KEY)),
transport: sqlTransport(postgres.admin),
});The usage block takes the same stripe option. Check the caller's permission (billing.manage) in your route before
you call checkout, portal or syncSeats: they act as the service role.
Checkout and the portal
const { url } = await billing
.checkout(organizationId, {
price: env.STRIPE_SEAT_PRICE,
quantity: "seats", // the tenant's seat count
successUrl: `${origin}/billing?done=1`,
cancelUrl: `${origin}/billing`,
email: user.email,
})
.orThrow();
redirect(url!);
const portal = await billing.portal(organizationId, {
returnUrl: `${origin}/billing`,
});checkout creates the Stripe customer the first time, with the tenant id in
metadata.organization_id, and links it. When two checkouts race, the first
link wins and both use that customer. The session carries the tenant in
client_reference_id and in the subscription's metadata, so the webhook can
link it even if the customer was created elsewhere. params merges more
Checkout Session parameters deeply: nested objects such as metadata and
subscription_data merge with the block's, and arrays such as line_items
replace them. The customer, client_reference_id and
metadata.organization_id (on the session and the subscription) are set
again afterwards, so your own metadata never drops the tenant link. portal returns not_found
(BILLING_NO_CUSTOMER) before the tenant has a customer.
cancelSubscription(organizationId) cancels the tenant's active subscription
in Stripe right away and returns its id, or undefined when there is none.
The data lifecycle purge calls it before it
deletes an organization.
Plans from your catalog
Most apps keep a plan catalog (plan keys, their prices per interval,
features). Point options.plans at it and checkout takes a plan key instead
of a price id:
billing: {
options: {
plans: {
table: "public.plan_prices", // or "plan_prices" in public
key: "plan_key", // default key
price: "stripe_price_id", // default stripe_price_id
interval: "interval", // optional: month, year
active: "is_active", // optional
variant: "pack", // optional: several prices per plan key
},
},
},await billing.checkout(organizationId, {
plan: "pro",
interval: "year",
successUrl,
});billing_plan_price(plan, billing_interval, variant) returns the price (the
monthly one when no interval is given), and an unknown plan fails with
BILLING_PLAN_UNKNOWN.
A plan key can have several prices besides the interval, such as credit
packs or seat tiers. Name the column that tells them apart in variant:
the row without a variant is the plan's default, and variant picks
another. items adds more line items to the same Checkout Session, each a
price or a plan key with its own interval, variant and quantity, for
add-on packages next to the plan:
await billing.checkout(organizationId, {
plan: "pro",
items: [{ plan: "credits", variant: "5000", quantity: 2 }],
successUrl,
});changePlan takes variant too.
Managing a subscription
These calls are for an admin console or the customer's own billing page. The
Stripe calls run with the block's Stripe client, so guard the route or action
with your own permission check (such as billing.manage).
await billing.changePlan(organizationId, { plan: "business" }); // or { price }
await billing.cancelAtPeriodEnd(organizationId, true); // false resumes it
const invoices = await billing
.invoices(organizationId, { limit: 20 })
.orThrow();
const methods = await billing.paymentMethods(organizationId).orThrow();
await billing.voidInvoice(organizationId, invoiceId);
await billing.markInvoiceUncollectible(organizationId, invoiceId);changePlan moves the subscription item to the new price with prorations
(prorationBehavior) and clears a scheduled cancellation unless
resume: false. invoices and paymentMethods read the Stripe Sync
Engine's stripe.invoices and stripe.payment_methods rows as stored
(billing_invoices, billing_payment_methods), newest first, for callers
with billing.read; they return [] until the Sync Engine has the tables.
voidInvoice and markInvoiceUncollectible act only on the tenant's own
invoices (BILLING_INVOICE_NOT_FOUND otherwise).
Subscriptions in full, and every tenant's billing
subscription(organizationId) returns the tenant's newest subscription as
the Sync Engine stores it, every column with its items, preferring an
active one (billing_subscription). Platform staff and back-office jobs read
every tenant's newest subscription with allSubscriptions, newest first:
const current = await billing.subscription(organizationId).orThrow();
const page = await billing
.allSubscriptions({ status: "past_due", limit: 50 })
.orThrow();
// [{ organizationId, customerId, row }]
const next = await billing
.allSubscriptions({
status: "past_due",
limit: 50,
cursor: page.at(-1)?.row.created as number,
})
.orThrow();allInvoices({ status, limit, before }) does the same for invoices, across
tenants, as { organizationId, customerId, row }, and platform staff also
read one tenant's invoices and paymentMethods.
These are open to platform staff with billing.read in the platform scope
(the module's viewAll permission, checked with is_platform()), and
allSubscriptions to the service role. subscription also answers members
with billing.read in the tenant.
An admin list that searches by tenant name, sorts by plan, period end or
amount, or counts rows needs typed columns it can join in SQL rather than
raw JSON pages. billing_platform_subscriptions() returns one row per
tenant (its newest subscription, preferring an active one) with tenant,
customer, customer_email and customer_name (from stripe.customers),
subscription, status, price, price_metadata (the price's Stripe
metadata), plan (the key from options.plans, when set), quantity,
amount (the price's unit amount times the quantity), currency,
recurring_interval (the price's month, year and so on),
current_period_start, current_period_end, cancel_at_period_end and
created. billing_platform_invoices() returns every invoice with
tenant, customer, invoice, subscription, number, status,
amount_due, amount_paid, amount_remaining, total, currency,
due_date, finalized_at and paid_at (from the invoice's status
transitions), hosted_invoice_url, invoice_pdf, customer_email and
customer_name (the invoice's, else the customer's), created and
updated_at (when the Sync Engine last wrote the row). Both read the Sync
Engine's tables when they exist, and are open to the service role and
platform staff with viewAll.
plan is null for a price that options.plans doesn't list. When your
plans keep their key in the price's metadata instead, fall back to it:
select s.tenant, coalesce(s.plan, s.price_metadata ->> 'plan') as plan
from better_supabase.billing_platform_subscriptions() s;A list of subscriptions for platform staff:
select o.name, s.plan, s.status, s.current_period_end, s.amount
from better_supabase.billing_platform_subscriptions() s
join public.organizations o on o.id = s.tenant
where o.name ilike '%acme%'
order by s.current_period_end
limit 50;allSubscriptions and allInvoices stay the paged JSON form of the same
reads.
A customer list includes tenants that have a customer but no subscription
or invoice yet. billing_platform_customers() returns one row per linked
customer with tenant, customer, email, name and created (from
stripe.customers, null before the Sync Engine has the row), for the
service role and platform staff with viewAll. In TypeScript,
allCustomers() reads it through billing_all_customers():
const customers = await billing.allCustomers().orThrow();
// [{ organizationId, customerId, email, name, created }]The billing contact
Stripe is the record for the billing contact: email, name, address, phone and
tax ids live on the Stripe customer, the Sync Engine mirrors them into
stripe.customers, and invoices use them. customerDetails(organizationId)
reads that row (billing_customer_details), and
updateCustomer(organizationId, { email, name, address, params }) writes the
customer, creating it first when the tenant has none. There is no second copy
in the module's tables to keep in sync.
Tax ids (VAT, GST and the other Stripe tax id types) live on the Stripe
customer too. taxIds(organizationId) lists them, addTaxId(organizationId, { type, value }) adds one (creating the customer when the tenant has none),
and removeTaxId(organizationId, taxId) removes one. Stripe validates the
value and reports verification.status for the types it checks:
await billing.addTaxId(organizationId, {
type: "eu_vat",
value: "DE123456789",
});
const taxIds = await billing.taxIds(organizationId).orThrow();
// [{ id, type, value, country, verification, created }]They need customers.listTaxIds, createTaxId and deleteTaxId on the
Stripe client, which the stripe package has. Guard them with your own
billing.manage check, as the other Stripe calls.
taxIds(organizationId, { from: "sync" }) reads the Sync Engine's
stripe.tax_ids instead, without a Stripe call, through
billing_tax_ids(tenant). That function checks billing.read (or platform
staff) itself and returns the same shape, newest first, with created in
epoch seconds as Stripe reports it (null when the row has none), so SQL
can use it too, for example in an admin list:
select t ->> 'type' as type, t ->> 'value' as value,
t ->> 'country' as country, t -> 'verification' ->> 'status' as verification
from jsonb_array_elements(better_supabase.billing_tax_ids(tenant)) t;It returns [] before the tenant has a customer or the Sync Engine has the
tax_ids table.
Reading a subscription in your own functions
Every reader above checks the caller, so a function that resolves the plan
for an ordinary member (say, to apply a limit) can't use them.
better_supabase.billing_tenant_subscription(tenant) returns what
billing_subscription returns without that check. No role may execute it,
not even service_role, and api writes no entry point for it: only
functions owned by the module's owner, such as your security definer
functions created by postgres, call it.
create function public.plan_price(tenant uuid)
returns text language sql stable security definer set search_path = '' as $$
select better_supabase.billing_tenant_subscription(tenant) -> 'items' -> 0 ->> 'price'
$$;Check what the caller may see in that function before you return anything beyond what a member may know.
Audit retention by plan
The audit module calls an audit_retention(tenant)
function when you write one, so retention can follow the plan the Sync
Engine reports:
create function public.audit_retention(tenant uuid)
returns interval language sql stable security definer set search_path = '' as $$
select case p.price_id
when 'price_enterprise' then interval '365 days'
when 'price_business' then interval '90 days'
else interval '7 days'
end
from (
select (better_supabase.billing_subscription_item(tenant) ->> 'price') as price_id
) p
$$;Seats
billing_seat_count(tenant) counts the tenant's memberships, or only those
with a role in options.seatRoles:
export default defineConfig({
sql: {
modules: {
billing: { options: { seatRoles: ["owner", "admin", "member"] } },
},
},
});syncSeats(organizationId) sets the active subscription item's quantity
(the seatPrice item, or the first one) to that count. Relay the membership
events from the outbox to seatSink() to keep it in
step:
await outbox.register("billing-seats", {
types: [
"organization.member_added",
"organization.member_removed",
"organization.member_left",
"organization.role_changed",
],
});
export const GET = outbox.relayRoute({
secret: env.CRON_SECRET,
consumers: { "billing-seats": billing.seatSink() },
});The sink syncs each tenant once per batch. The Stripe idempotency key holds the item, the old and new quantity and the outbox event id, so a relay that retries the batch doesn't change the subscription twice. A failed update throws, which leaves the batch for the next run.
Stripe webhooks
Store Stripe events in the webhook inbox, then hand them to
handleStripeEvent:
import { stripeInboxVerify } from "better-supabase/blocks/billing";
import { createWebhookInbox } from "better-supabase/blocks/jobs";
export const inbox = createWebhookInbox(postgres.admin, {
source: "stripe",
verify: stripeInboxVerify(env.STRIPE_WEBHOOK_SECRET),
});
export const POST = (request: Request) => inbox.receive(request);
// In the inbox cron route:
await inbox.process((message) =>
billing.handleStripeEvent(message.payload).orThrow(),
);| Stripe event | What the block does |
|---|---|
checkout.session.completed | Links the customer to the tenant; emits billing.checkout_completed |
customer.subscription.created | Links from metadata.organization_id if needed; emits billing.subscription_created and invalidates sessions |
customer.subscription.updated | Emits billing.subscription_updated and invalidates sessions |
customer.subscription.deleted | Emits billing.subscription_deleted and invalidates sessions |
Other events are ignored. Session invalidation uses
entitlementMembers, so members read the new
plan's entitlements on their next request. verifyStripeWebhook checks the
Stripe-Signature header with WebCrypto, without the stripe package.
Block events
billing.customer_linked, billing.checkout_completed,
billing.subscription_created, billing.subscription_updated,
billing.subscription_deleted and billing.seats_synced carry
organizationId and, where they apply, customerId, subscriptionId,
status, quantity, previousQuantity and stripeEventId. Listen with
onBlockEvent(bs, "billing.*", handler).
Functions
| Function | Granted to | Returns |
|---|---|---|
billing_customer(tenant) | authenticated, service_role | the customer id, for callers with billing.read |
billing_status(tenant) | authenticated, service_role | { customer, seats, subscription } |
billing_subscription(tenant) | authenticated, service_role | the newest stripe.subscriptions row with items, for billing.read or platform staff |
billing_tenant_subscription(tenant) | none (the owner) | the same row without checking the caller, for your own security definer functions |
billing_all_subscriptions(for_status, max_rows, before_created) | authenticated, service_role | [{ tenant, customer, subscription }], for platform staff |
billing_all_invoices(for_status, max_rows, before_created) | authenticated, service_role | [{ tenant, customer, invoice }], for platform staff |
billing_platform_customers() | authenticated, service_role | every linked customer: tenant, customer, email, name, created, for platform staff |
billing_all_customers() | authenticated, service_role | the same rows as jsonb, for allCustomers() |
link_billing_customer(tenant, customer) | service_role | the linked customer |
billing_customer_tenant(customer) | service_role | the tenant id |
billing_seat_count(tenant) | service_role | the seat count |
billing_subscription_item(tenant, price) | service_role | { subscription, item, price, quantity, status } or null |
billing_plan_price(plan, billing_interval, variant) | authenticated, service_role | the plan's Stripe price from options.plans, or null |
billing_invoices(tenant, max_rows) | authenticated, service_role | the customer's stripe.invoices rows, for billing.read |
billing_payment_methods(tenant) | authenticated, service_role | the customer's stripe.payment_methods rows |
billing_customer_details(tenant) | authenticated, service_role | the customer's stripe.customers row |
billing_tax_ids(tenant) | authenticated, service_role | the customer's stripe.tax_ids as [{ id, type, value, country, verification, created }] |
Last updated on
Usage and quotas
Count usage per tenant and meter with idempotent increments, enforce quotas per tenant or plan in RLS and RPCs, and report usage to Stripe meters.
Feature flags
Feature flags per tenant and per user, with targeting rules, overrides and percentage rollouts, evaluated the same way in RLS and in an OpenFeature provider.