Payments
Sell team subscriptions with the Better Auth Stripe plugin: settings, plans, gating and webhooks.
Billing is per team and runs through the Better Auth Stripe plugin. The team is the Stripe customer: Stripe Checkout starts a subscription, the Customer Portal manages it, and webhooks keep the subscription table up to date.
How a purchase flows
- A visitor clicks a plan on
/pricing. The button posts the plan's Stripe product id to/api/stripe/checkout. Visitors who aren't signed in go to/signupfirst and come back to the pricing page. - The route asks the plugin to start Checkout for the active team. Only the team's owners and admins can buy or change a plan.
- On its first checkout the plugin creates the team's Stripe customer. After payment Stripe returns to the Team page, which shows the plan.
- Owners and admins manage the subscription from the Team page, which opens the Stripe Customer Portal.
- Stripe sends subscription events to
/api/stripe/webhook, which hands them to the plugin.
Settings
All three are Worker secrets (bunx wrangler secret put <NAME>; locally, .dev.vars):
STRIPE_SECRET_KEY: your Stripe secret key. A restricted key works if it can write Customers, Checkout Sessions, Subscriptions and the Billing Portal, and read Products.STRIPE_WEBHOOK_SECRET: the signing secret of your webhook endpoint.STRIPE_PRODUCT_SITE(optional): only products whosesitemetadata equals this value become plans.
Plans
Plans come from Stripe, not from code. Every active product with a recurring default price is a plan, named after its product id; with STRIPE_PRODUCT_SITE set, only the tagged ones. The list is cached for five minutes, so a new product or price shows up within that time.
To change a price, add a new price to the product and make it the default. Existing subscribers keep their old price until they switch plans.
Plans have no free trial. The plugin supports one (freeTrial: { days } on a plan, one trial per team), so add it in getBillingPlans() in src/lib/billing.server.ts if you want trials, and make the pricing page say so.
Set up products
Create the product in Stripe
Add a product with a default recurring price in the Stripe Dashboard.
Tag it for this site
If you set STRIPE_PRODUCT_SITE, add product metadata site with the same value. Untagged products aren't plans.
Copy its id
Copy the product id. It starts with prod_.
Link it to a plan
In the CMS, open the Pricing page, edit the plan in the Pricing block and paste the id into its Stripe product field. Save and publish.
A plan without a product id keeps a normal link button, which suits a free tier or a "contact us" plan.
Gating paid features
Subscriptions are rows in the subscription table, keyed by team. Read the team's current one with getTeamSubscription:
import { getTeamSubscription } from '@/db/queries';
const sub = await getTeamSubscription(Astro.locals.organizationId);
const isPaid = sub?.status === 'active' || sub?.status === 'trialing';
if (!isPaid) return Astro.redirect('/pricing');To gate by plan, compare sub.plan: it's the product id (lowercased), which can't change, unlike the product name. Gate on the server.
Webhooks
In the Stripe Dashboard, add an endpoint at https://yourdomain.com/api/stripe/webhook and subscribe to checkout.session.completed, customer.subscription.created, customer.subscription.updated and customer.subscription.deleted. Put its signing secret in the STRIPE_WEBHOOK_SECRET secret.
Testing
Forward webhooks to your machine while you develop:
bun run stripe:webhookCopy the whsec_… value it prints into .dev.vars as STRIPE_WEBHOOK_SECRET, and use a test-mode STRIPE_SECRET_KEY. Pay with the test card 4242 4242 4242 4242, any future expiry date and any three-digit CVC. In live mode, a 100% off promotion code lets you run a real checkout for free.