Launch NowDocs

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

  1. Plans are rows in the plan table (free, pro, ultra), seeded by a script.
  2. Paid plans are linked to Stripe prices through the STRIPE_PRO_PRICE_ID and STRIPE_ULTRA_PRICE_ID environment variables.
  3. The pricing and billing pages call authClient.subscription.upgrade(), which opens Stripe Checkout.
  4. Stripe sends events to /api/webhook/stripe. The plugin updates the subscription table.
  5. Your code reads the current plan with getUserPlan() or getOrgPlan() from lib/plans.ts.
FileRole
lib/stripe.tsThe Stripe SDK client (stripeClient).
lib/auth.tsThe stripe() plugin configuration: plans, authorization, callbacks.
lib/plans.tsgetAllPlans, getUserPlan, getOrgPlan, formatPrice, formatLimit, isActiveSubscriptionStatus.
app/api/webhook/stripe/route.tsStripe webhook endpoint.
scripts/stripe-create-products.tsCreates the Stripe products and prices.
scripts/stripe-update-features.tsPushes descriptions, features and limits to the Stripe products.
scripts/seed-plans.tsUpserts 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.tsxOrganization billing page.

Plans

The default plans are defined in scripts/seed-plans.ts:

PlanPriceProjectsSeatsOther limits
freeFree11usageQuota: "reduced", watermark: true, support: "community"
pro$10 / monthUnlimited (-1)5usageQuota: "increased", watermark: false, support: "email", history: "extended"
ultra$50 / monthUnlimited (-1)20usageQuota: "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:

lib/auth.ts
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:

.env
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:products

The 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-features

This 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:plans

The 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/stripe

The 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:

app/api/webhook/stripe/route.ts
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.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.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.

Test mode and live mode have different price IDs and webhook secrets. Copying test values to production makes checkout fail.

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:

lib/auth.ts
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: true creates a Stripe customer for each new user. An organization's Stripe customer is created on its first checkout.
  • authorizeReference lets 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:

features/organization/organization-billing.tsx
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().
features/pricing/pricing-subscribe-button.tsx
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().
ActionClient callWhat happens
ManageauthClient.subscription.billingPortal()Opens the Stripe customer portal (payment methods, invoices, plan).
CancelauthClient.subscription.cancel()Opens the portal's cancellation flow. The subscription stays active until the end of the period.
RestoreauthClient.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:

lib/plans.ts
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 for active and trialing, 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:

lib/projects-actions.ts
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 PLANS in scripts/stripe-create-products.ts (and scripts/stripe-update-features.ts), and the matching rows in scripts/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_ID variable to lib/env.ts, and map it in priceIdByPlan in lib/auth.ts.
  • Hide a plan: set its active column to false. The plugin stops offering it at checkout. getAllPlans() returns every row regardless of active, 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.

Next steps