Skip to content
Browse documentation
On this page

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

  1. 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 /signup first and come back to the pricing page.
  2. 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.
  3. On its first checkout the plugin creates the team's Stripe customer. After payment Stripe returns to the Team page, which shows the plan.
  4. Owners and admins manage the subscription from the Team page, which opens the Stripe Customer Portal.
  5. 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 whose site metadata 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

  1. Create the product in Stripe

    Add a product with a default recurring price in the Stripe Dashboard.

  2. Tag it for this site

    If you set STRIPE_PRODUCT_SITE, add product metadata site with the same value. Untagged products aren't plans.

  3. Copy its id

    Copy the product id. It starts with prod_.

  4. 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:webhook

Copy 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.