Billing
Stripe subscriptions for users and organizations with the Better Auth Stripe plugin, a plans table, webhooks, a pricing page and billing pages.
Launch Now bills with Stripe through the @better-auth/stripe plugin. Users and
organizations can each subscribe to a monthly plan. Checkout, the customer portal, cancellation and
webhooks are handled by the plugin; plan names, prices, features and limits live in a plan table in
your database.
How it works
- Plans are rows in the
plantable (free,pro,ultra), seeded by a script. - Paid plans are linked to Stripe prices through the
STRIPE_PRO_PRICE_IDandSTRIPE_ULTRA_PRICE_IDenvironment variables. - The pricing and billing pages call
authClient.subscription.upgrade(), which opens Stripe Checkout. - Stripe sends events to
/api/webhook/stripe. The plugin updates thesubscriptiontable. - Your code reads the current plan with
getUserPlan()orgetOrgPlan()fromlib/plans.ts.
| File | Role |
|---|---|
lib/stripe.ts | The Stripe SDK client (stripeClient). |
lib/auth.ts | The stripe() plugin configuration: plans, authorization, callbacks. |
lib/plans.ts | getAllPlans, getUserPlan, getOrgPlan, formatPrice, formatLimit, isActiveSubscriptionStatus. |
app/api/webhook/stripe/route.ts | Stripe webhook endpoint. |
scripts/stripe-create-products.ts | Creates the Stripe products and prices. |
scripts/stripe-update-features.ts | Pushes descriptions, features and limits to the Stripe products. |
scripts/seed-plans.ts | Upserts the plan rows. |
app/pricing/page.tsx, features/pricing/* | Public pricing page and subscribe button. |
app/(app)/account/billing/page.tsx, features/billing/* | Personal billing page, invoices, portal and cancel actions. |
app/(app)/orgs/[orgSlug]/settings/billing/page.tsx | Organization billing page. |
Plans
The default plans are defined in scripts/seed-plans.ts:
| Plan | Price | Projects | Seats | Other limits |
|---|---|---|---|---|
free | Free | 1 | 1 | usageQuota: "reduced", watermark: true, support: "community" |
pro | $10 / month | Unlimited (-1) | 5 | usageQuota: "increased", watermark: false, support: "email", history: "extended" |
ultra | $50 / month | Unlimited (-1) | 20 | usageQuota: "unlimited", support: "priority", integrations: true, analytics: "advanced" |
Each row has a name (the machine name used everywhere), displayName, description,
monthlyPriceCents, billingInterval, a features list shown on the pricing page, a limits object,
sortOrder and an active flag. A limit of -1 means unlimited.
The plugin builds its plan list from the database on each request:
plans: async () => {
const rows = await db
.select()
.from(plan)
.where(eq(plan.active, true))
.orderBy(plan.sortOrder)
const priceIdByPlan: Record<string, string | undefined> = {
pro: env.STRIPE_PRO_PRICE_ID,
ultra: env.STRIPE_ULTRA_PRICE_ID,
}
// ... keeps only rows with a price ID
},Rows without a price ID, such as free, are skipped: the free plan is simply the absence of a
subscription.
Set up Stripe
Add your Stripe keys
Use your test mode secret key while developing:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Both are required by lib/env.ts. You get the webhook secret in the webhook step below.
Create the products and prices
pnpm stripe:productsThe script creates a "Pro" and an "Ultra" product with monthly USD prices ($10 and $50). Prices get the
lookup keys pulse-pro-monthly and pulse-ultra-monthly, and products are tagged with
metadata.app = "pulse". The prefix comes from siteConfig.slug. The script is idempotent: it reuses a
price that already has the lookup key.
At the end it prints the price IDs:
=== Copy these into your .env ===
STRIPE_PRO_PRICE_ID=price_...
STRIPE_ULTRA_PRICE_ID=price_...Copy them into .env.
Sync the marketing features (optional)
pnpm stripe:update-featuresThis sets the description, the marketing features list and features and limits metadata on each
Stripe product, so Stripe Checkout and the customer portal show the same features as your pricing page.
Seed the plans table
pnpm db:seed:plansThe script upserts the free, pro and ultra rows by name, so you can run it again after editing
scripts/seed-plans.ts.
Forward webhooks locally
Install the Stripe CLI, then forward events to the webhook route
while pnpm dev runs:
stripe listen --forward-to localhost:3000/api/webhook/stripeThe CLI prints a signing secret (whsec_...). Set it as STRIPE_WEBHOOK_SECRET and restart the dev
server.
Good to know: The product name, lookup keys and API key prefix all derive from siteConfig.slug in
lib/config/site-config.ts. Rename the product before running pnpm stripe:products, or the prices
are created with the pulse- prefix.
Webhooks
The webhook route hands the request to the plugin, which verifies the stripe-signature header with
STRIPE_WEBHOOK_SECRET:
export async function POST(request: NextRequest) {
try {
await auth.api.stripeWebhook({ request })
return NextResponse.json({ success: true })
} catch (error) {
console.error("Stripe webhook error:", error)
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 400 }
)
}
}The plugin keeps the subscription table in sync from these events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
lib/auth.ts also listens to invoice.payment_failed in onEvent to send a billing.payment_failed
notification. The onSubscriptionComplete and onSubscriptionCancel callbacks send
billing.subscription_started and billing.subscription_canceled. Notifications go to the user, or to
the owners and admins of the organization that owns the subscription.
In production
In the Stripe Dashboard, add a webhook endpoint pointing to
https://your-domain.com/api/webhook/stripe, select the five events above, and set its signing secret as
STRIPE_WEBHOOK_SECRET in your production environment. Use live mode keys and run
pnpm stripe:products against the live account to get live price IDs.
User and organization subscriptions
Every subscription row has a referenceId: the user ID for a personal subscription, the organization ID
for an organization subscription. The plugin is configured for both:
stripe({
stripeClient,
stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET,
createCustomerOnSignUp: true,
organization: { enabled: true },
subscription: {
enabled: true,
authorizeReference: async ({ user, referenceId, action }) => {
if (referenceId === user.id) return true
// ... looks up the user's membership in the organization
if (!membership) return false
if (action === "list-subscription") return true
return hasRole(membership.role, "owner", "admin")
},
// ...
},
}),createCustomerOnSignUp: truecreates a Stripe customer for each new user. An organization's Stripe customer is created on its first checkout.authorizeReferencelets users manage their own subscription, lets any organization member list the organization's subscription, and restricts every other action to owners and admins.
On the client, pass customerType and referenceId to target an organization. Leave them out for a
personal subscription:
const customer = {
customerType: "organization" as const,
referenceId: organizationId,
returnUrl: billingPath,
}Pricing page
app/pricing/page.tsx lists every plan from getAllPlans() with PricingCard, and a feature comparison
table (features/pricing/pricing-comparison.tsx) whose rows are static, in
features/pricing/pricing-comparison-data.ts. For a signed-in user, the current plan comes from
getUserPlan().
PricingSubscribeButton (features/pricing/pricing-subscribe-button.tsx) picks what to render:
- Signed out: a paid plan sends the user to
/auth/signin?callbackURL=/pricing; the free plan links to/auth/signup. - Current plan with a subscription: a Manage subscription link to the billing page.
- Another paid plan: Choose or Switch to, which calls
authClient.subscription.upgrade().
const { data, error } = await authClient.subscription.upgrade({
plan: planName,
subscriptionId: hasActiveSubscription
? (activeSubscriptionId ?? undefined)
: undefined,
successUrl,
cancelUrl,
returnUrl,
customerType,
referenceId,
seats,
disableRedirect: true,
})
if (data?.url) {
window.location.href = data.url
return
}
// Plan switched in place (no redirect needed).After a successful checkout, Stripe redirects to /account/billing?subscribed=1. SubscribedToast
(features/billing/subscribed-toast.tsx) shows a success toast once and removes the parameter.
Billing pages
/account/billing (personal) and /orgs/[orgSlug]/settings/billing (organization) show:
- Current plan with its status (
active,trialing,past_due, or "Cancels soon") and renewal or end date. - Manage subscription, Cancel subscription or Restore subscription from
BillingActions(features/billing/billing-actions.tsx). - Change plan cards using
PricingSubscribeButton. - Invoices from
listInvoices()(features/billing/invoices.ts), with links to Stripe's hosted invoice or PDF. - Usage: the current plan's limits, formatted with
formatLimit().
| Action | Client call | What happens |
|---|---|---|
| Manage | authClient.subscription.billingPortal() | Opens the Stripe customer portal (payment methods, invoices, plan). |
| Cancel | authClient.subscription.cancel() | Opens the portal's cancellation flow. The subscription stays active until the end of the period. |
| Restore | authClient.subscription.restore() | Undoes a scheduled cancellation. |
Each of these calls uses disableRedirect: true and navigates to the returned URL itself, so the button
can show a loading state and an error toast.
Good to know: The portal and cancel flows use the Stripe customer portal. Enable and configure it in the Stripe Dashboard (Settings, Billing, Customer portal) in both test and live mode.
Past-due subscriptions
When a renewal payment fails, Stripe marks the subscription past_due and retries. lib/plans.ts keeps
counting a past_due subscription as the current plan, and the billing pages show PastDueBanner
(features/billing/past-due-banner.tsx) with an Update payment method button that opens the portal.
Organization members who can't manage billing see a message asking an owner or admin instead.
Checking a user's plan in code
lib/plans.ts returns the plan that currently applies to a user or an organization:
export function getUserPlan(userId: string) {
return getPlanForReference(userId)
}
export function getOrgPlan(organizationId: string) {
return getPlanForReference(organizationId)
}Both return { plan, subscription } for the latest subscription whose status is active, trialing
or past_due. When there is none, both are null, which means the free plan.
const { plan, subscription } = await getUserPlan(user.id)
const planName = plan?.name ?? "free"Other helpers:
formatPrice(cents)returns"Free"for 0, otherwise a whole dollar amount like"$10".formatLimit(value)returns"Unlimited"for negative numbers, otherwise the value as a string.isActiveSubscriptionStatus(status)is true foractiveandtrialing, the statuses that can be changed, canceled or restored.
Enforcing a limit
The boilerplate displays limits but does not enforce them: that depends on your product. To enforce one,
read the plan in the server action before the write. This example caps projects with the projects
limit, falling back to the free row for users without a subscription:
import { getAllPlans, getUserPlan } from "@/lib/plans"
import { ActionError } from "@/lib/safe-action"
import { count, eq } from "drizzle-orm"
// ... existing imports (db, projects, authAction, ...)
async function assertProjectLimit(userId: string) {
const { plan } = await getUserPlan(userId)
const current =
plan ?? (await getAllPlans()).find((row) => row.name === "free")
const max = current?.limits.projects
if (typeof max !== "number" || max < 0) return // unlimited
const [{ value }] = await db
.select({ value: count() })
.from(projects)
.where(eq(projects.userId, userId))
if (value >= max) {
throw new ActionError(
`Your plan allows ${max} project${max === 1 ? "" : "s"}. Upgrade to add more.`
)
}
}Call it at the start of createProject. For organization-scoped data, use getOrgPlan(organizationId)
instead. Always enforce limits on the server; the UI can use the same check to disable buttons.
Customization
- Change prices or names: edit
PLANSinscripts/stripe-create-products.ts(andscripts/stripe-update-features.ts), and the matching rows inscripts/seed-plans.ts. Stripe prices are immutable, so create new ones rather than editing an existing price. - Add a plan: add it to the three scripts, add a
STRIPE_<NAME>_PRICE_IDvariable tolib/env.ts, and map it inpriceIdByPlaninlib/auth.ts. - Hide a plan: set its
activecolumn tofalse. The plugin stops offering it at checkout.getAllPlans()returns every row regardless ofactive, so also filter it on the pricing and billing pages if you want it hidden there. - Change the comparison table: edit
features/pricing/pricing-comparison-data.ts.