Subscribe & Checkout
How a user upgrades from free to a paid plan via Stripe Checkout and returns on success
The upgrade flow takes an organization from the free plan to a paid plan (pro or enterprise) using Stripe Checkout. There are no hand-rolled checkout API routes. UpgradeButton calls the upgradeSubscriptionAction server action, which wraps auth.api.upgradeSubscription. The plugin creates a Stripe Checkout Session (or, for an already-subscribed org, a portal update-confirm session) and returns a redirect URL. After payment, Stripe sends the user back to a success page that independently verifies the session before showing confirmation.
Where the upgrade starts
The billing page at apps/web/app/[locale]/(app)/[orgSlug]/billing/page.tsx loads org context with getActiveOrganization(orgSlug) and wraps the page in OrgPermissionGate requiring organization: ["update"], so only org owners/admins reach the upgrade UI. It renders ChangePlan, which renders one PlanCard per plan.
In apps/web/app/[locale]/(app)/[orgSlug]/billing/components/plan-card.tsx, comparePlans(currentPlan, plan.name) classifies each card as same, upgrade, or downgrade. Only upgrade cards for pro or enterprise render UpgradeButton:
{isUpgrade && (plan.name === "pro" || plan.name === "enterprise") && (
<UpgradeButton ... />
)}Downgrades use a different control (DowngradeButton) and a different code path (the changePlanAction server action / cancel flow) — they do not go through Stripe Checkout.
The server action: upgradeSubscriptionAction
Checkout is kicked off from UpgradeButton (upgrade-button.tsx) with useTransition. There is no upgrade-form.tsx and no client subscription.upgrade() call in this UI. The button calls upgradeSubscriptionAction in billing/actions.ts:
const result = await auth.api.upgradeSubscription({
headers: await headers(),
body: {
plan: validated.planName, // "pro" | "enterprise"
annual: validated.billingInterval === BILLING_INTERVALS.YEARLY,
referenceId: validated.organizationId,
successUrl: `${orgBase}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${orgBase}/checkout/canceled`,
returnUrl: `${orgBase}/billing`,
},
});
return { success: true, data: { url: result?.url ?? undefined } };On success the client assigns window.location.href = result.data.url. Only the error branch runs in-page, surfacing a toast (errors.startCheckout).
Key parameters (built on the server from the action input):
plan— the plan name (pro/enterprise), not a Stripe price ID. The plugin maps the name to a Stripe price using the plan catalog (see below).annual— boolean derived from the selectedbillingInterval. When true, the plugin uses the plan'sannualDiscountPriceId; otherwise itspriceId.referenceId— theorganizationId. This is what makes billing organization-scoped rather than per-user; the resultingSubscriptionrow stores this in itsreferenceIdcolumn.successUrl— app callback at/[locale]/[orgSlug]/checkout/success?session_id={CHECKOUT_SESSION_ID}. The plugin sets Stripe'ssuccess_urlto{baseURL}/subscription/success?callbackURL=…&checkoutSessionId={CHECKOUT_SESSION_ID}(GET /api/auth/subscription/success), then redirects to this callback. Stripe does not hit the app URL first.cancelUrl— points at/[locale]/[orgSlug]/checkout/canceled.returnUrl— the org billing page; used when the plugin skips Checkout and sends the user through the customer portal instead (existing paid subscription).
Which URL comes back depends on the org's subscription state:
- No active subscription — the plugin creates a Stripe Checkout session;
successUrl/cancelUrlapply. - Active subscription (e.g. Pro → Enterprise) — the plugin skips Checkout and redirects to the Stripe customer portal's
subscription_update_confirmflow. ThesuccessUrl/cancelUrlare ignored; the user returns viareturnUrl. See Plan Changes & Cancellation.
Server configuration: the Stripe plugin
The @better-auth/stripe plugin is wired up in packages/auth/server.ts (full config in Payment Overview); for checkout specifically, the relevant pieces are subscription.plans and subscription.authorizeReference:
- Plan catalog —
buildStripePlans(...)inpackages/auth/plans.tsbuilds theStripePlan[]array, attachingpriceId(monthly) andannualDiscountPriceId(annual) to theproandenterpriseplan configs from theSTRIPE_*_PRICE_ID_*env vars. The plugin usesplan+annualfrom the action to pick the right price. - Authorization —
upgradeSubscriptionActiondoes not callhasOrgPermission. The plugin'sauthorizeReferencedelegates toauthorizeBillingActioninpackages/auth/billing-authorize.ts. Forupgrade-subscriptionthis callscanManageBilling(userId, referenceId), allowing only orgowner/adminmembers (filtered byuserId+organizationId+ role). This server-side check is the real security boundary — the page-levelOrgPermissionGateis only UX. - The plugin mounts the checkout/portal/webhook handlers under
/api/auth/*.cancelUrlis passed through to Stripe Checkout.successUrlis a callback after the plugin's/subscription/successhop, not Stripe'ssuccess_url.
Returning on success: independent verification
When Stripe redirects back, the success page at apps/web/app/[locale]/(app)/[orgSlug]/checkout/success/page.tsx reads session_id from searchParams, loads org context via getActiveOrganization, and calls verifyCheckoutSession(sessionId, orgContext.organizationId).
verifyCheckoutSession in apps/web/lib/billing/checkout-queries.ts does not trust the redirect. It:
- Returns
missing_sessionifsession_idis absent. - Retrieves the session via
stripe.checkout.sessions.retrieve(sessionId)(returnsstripe_erroron failure). - Confirms payment:
payment_statusispaidorno_payment_requiredandstatus === "complete", otherwisenot_paid. - Calls
findOwningSubscription, which queries the Better AuthSubscriptiontable for a row whosereferenceIdmatches this org and whosestripeSubscriptionIdorstripeCustomerIdmatches the session. If none is found it returnswrong_org.
The page does not call revalidateOrgPage. On ok it renders CheckoutSuccessCard (.../checkout/success/components/checkout-success-card.tsx), which looks up PLANS[planName] and displays the plan's display name and feature list. The not_paid reason renders CheckoutProcessingCard (session not yet paid/complete). wrong_org, missing_session, and stripe_error render CheckoutErrorCard.
A missing owning Subscription row is wrong_org → CheckoutErrorCard. That is a cross-org / unmatched session, not a first-checkout race: the plugin creates an incomplete Subscription row (with referenceId + stripeCustomerId) before checkout.sessions.create, and /subscription/success updates it before the app page loads. findOwningSubscription also matches stripeCustomerId.
Verification result states
End-to-end sequence
Notes
- The webhook (plugin upsert, then
onSubscriptionCompleteemail) and the success-page redirect are independent. The success page does not depend on the webhook having fired to read the Stripe session.wrong_orgis cross-org / no owning row — not “redirect landed before upsert”. - Free is never a Checkout target.
buildStripePlansonly emitsproandenterprise; downgrades tofreeare routed to the cancel flow bychangePlanActioninbilling/actions.ts.
Source files
apps/web/app/[locale]/(app)/[orgSlug]/billing/page.tsx— billing page,getActiveOrganization,OrgPermissionGateapps/web/app/[locale]/(app)/[orgSlug]/billing/components/plan-card.tsx— upgrade/downgrade classificationapps/web/app/[locale]/(app)/[orgSlug]/billing/components/upgrade-button.tsx—useTransition+upgradeSubscriptionActionapps/web/app/[locale]/(app)/[orgSlug]/billing/actions.ts—upgradeSubscriptionAction,changePlanActionapps/web/lib/billing/checkout-queries.ts—verifyCheckoutSessionapps/web/lib/billing/subscription-queries.ts—getActiveSubscription,getCurrentPlanForOrg,getOrgUsageStatsapps/web/app/[locale]/(app)/[orgSlug]/checkout/success/page.tsx— success page + result renderingapps/web/app/[locale]/(app)/[orgSlug]/checkout/success/components/checkout-success-card.tsx— confirmation cardapps/web/app/[locale]/(app)/[orgSlug]/checkout/success/components/checkout-error-card.tsx—wrong_org/missing_session/stripe_errorpackages/auth/server.ts—@better-auth/stripeplugin config,authorizeReferencepackages/auth/plans.ts—PLANS,buildStripePlans, price-id mappingpackages/auth/billing-authorize.ts—authorizeBillingAction/canManageBilling