Subscriptions with Stripe Checkout, verified webhooks, and a local entitlement table.
Onekit bills the people who use your app through Stripe. The Better Auth Stripe plugin provides checkout, the customer billing portal, cancel and restore, and a signature-verified webhook. A subscription table mirrors Stripe so entitlement checks never call Stripe at request time.
Billing is optional. Without Stripe keys the pricing page still renders the example plans, the checkout buttons are disabled, and the account page hides the plan section.
tax.automatic off.src/config/billing.ts: the default plan expects onekit_pro_monthly and onekit_pro_yearly.https://your-domain/api/auth/stripe/webhook with these events: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted..env, then restart:STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…
The server refuses a partial pair. Use test-mode keys locally and separate live keys per deployed stage. For deployment set infraConfig.billing to true and the StripeSecretKey and StripeWebhookSecret SST secrets; see Deployment.
Locally, forward webhooks with the Stripe CLI and use the signing secret it prints:
stripe listen --forward-to localhost:8081/api/auth/stripe/webhook
Checkout runs with Stripe Tax enabled, so Stripe computes sales tax or VAT from the customer’s address and adds it to the first and every renewal invoice. Business customers can enter a tax ID, which applies reverse-charge rules where they exist.
This requires Stripe Tax to be activated in the Stripe account: Settings, Tax, then add the business origin address and the registrations for the places you collect in. Give the product a tax code as well, for example txcd_10103001 for software as a service sold to consumers or txcd_10103000 for business use; without one, Stripe treats the product as exempt and collects nothing. Stripe rejects checkout with an automatic_tax error until that is done. To ship without it, set tax.automatic to false in src/config/billing.ts. Stripe Tax is a paid Stripe feature billed per transaction.
src/config/billing.ts is public configuration bundled into clients. Each plan has a stable name (stored on subscriptions), display copy and prices, the Stripe lookup keys, optional annual pricing, an optional free trial, and a feature list for the pricing page. Stripe remains the source of truth for what is charged; the display prices are only text.
Changing a plan’s name orphans existing subscriptions with the old name. Add a new plan instead and let people move over.
startCheckout({ workspaceId, plan, annual, successUrl, cancelUrl }) in src/auth/billing.ts, which adds customerType: 'organization' and the workspace’s referenceId. The server re-checks that the caller is an owner of that workspace, creates or reuses its Stripe customer, writes an incomplete subscription row, and returns a Checkout URL. On web the browser follows it; on native it opens in the system browser.checkout.session.completed. The server verifies the signature, then updates the row with the Stripe subscription id, status, period, and trial dates. Later customer.subscription.updated and .deleted events keep it current. Deliveries are idempotent: repeats update the same row.getSubscription(database, scope) in src/server/billing.ts returns the workspace’s active or trialing subscription from the local table. /api/me includes it, and the billing page reads it from there.?checkout=success on the account page shows a confirming message until the webhook lands, which is usually within a second or two.
Deleting an account that owns a workspace with an active or trialing subscription is refused with SUBSCRIPTION_ACTIVE; owners cancel first so no Stripe subscription is left billing a workspace nobody can reach. A member leaving a paying workspace is ordinary and the subscription outlives them.
A subscription references a workspace, not the person who bought it — subscription.reference_id is an organization id. Plan caps count the workspace too (src/server/limits.ts), so the two agree: a colleague in a paying workspace is on the paid plan, and a workspace stays Pro when the member who checked out leaves.
Three things follow, and the first is the one that will catch you:
customerType: 'organization' plus a referenceId. An action that omits them takes the plugin’s user path and answers about user.id instead, with no error at all — a subscribed workspace simply lists nothing. src/auth/billing.ts passes both on all four actions so there is one place to get it right.referenceId is passed explicitly rather than defaulted. The Stripe plugin defaults an organization reference to session.activeOrganizationId, and this kit never writes that column for a fresh account: the personal workspace is created by a database hook, and request scoping falls back to the oldest membership instead of writing it back on a read. Left to the default, checkout answers 400.authorizeReference re-derives membership and role from the database for every action, so a forged referenceId is refused. Spending needs billing: manage (owner); listing needs billing: read (owner or admin). The plugin answers 401 for a refusal.Who may change what a workspace pays is in the role table under Workspaces and roles.
Check the local table, never the client:
import { getSubscription } from '@/server/billing'
import { jsonResponse, withSession } from '@/server/http'
export const GET = (request: Request) =>
withSession(request, async ({ database, scope }) => {
const subscription = await getSubscription(database, scope)
if (subscription?.plan !== 'pro') {
return Response.json({ error: 'Upgrade required' }, { status: 402 })
}
// Pro-only work here, for this workspace.
return jsonResponse({ ok: true })
})
Through withSession rather than getSubscription(getDatabase(), session.user.id), which is what this example used to say: entitlement belongs to the workspace, and withSession is what resolves which one — it also hands you the transaction the CRM tables’ row-level policies need. getSubscription takes the Scope rather than an id precisely so the old call could not keep compiling while answering about the wrong subject.
Hiding a button is not authorization; keep this check on every paid API.
Stripe Checkout and the portal are web pages, so the native client opens them in the system browser and returns to the app afterwards. Apple and Google require in-app purchases for digital goods consumed inside the app; check the store rules for your product before offering subscriptions from the mobile apps. Onekit does not include StoreKit or Play Billing.
bun run test:billing exercises checkout, signed and forged webhooks, duplicate deliveries, entitlement, cancel, restore, the portal, and subscription deletion against an in-memory PostgreSQL with a fake Stripe client. Use Stripe’s test cards, such as 4242 4242 4242 4242, for real end-to-end checks.
Reference: Better Auth Stripe plugin, Stripe Checkout, Stripe webhooks.