Billing & subscriptions
Stripe-powered subscription and lifetime checkout, the customer portal, and how webhooks keep each user's plan in sync.
Valcraven ships a complete Stripe billing integration: hosted checkout for recurring subscriptions and one-time "lifetime" purchases, the Stripe customer portal for self-service management, and a webhook handler that keeps every user's plan and subscriptionStatus in sync with Stripe. The Stripe client lives in lib/stripe.ts; the routes live under app/api/stripe/.
You don't build the billing plumbing — you configure a few env vars and Stripe prices, then read the user's plan where your feature needs to gate access.
How it fits together
lib/stripe.tsexposes a single lazy-initialized client viagetStripe(). It readsSTRIPE_SECRET_KEYon first use, so importing the module never crashes when Stripe isn't configured.- Four API routes under
app/api/stripe/handle checkout, the portal, status reads, and the webhook. - The
usertable carries the billing state as custom columns:plan(default"free"),stripeCustomerId,stripeSubscriptionId, andsubscriptionStatus(default"inactive"). See Data layer. - A cron job (
sync-stripe-status) reconciles subscription status hourly as a backstop to the webhook.
The Stripe client
import { getStripe } from "@/lib/stripe";
const stripe = getStripe();getStripe() returns a memoized Stripe instance configured with the SDK's pinned API version. It's server-only — never import it into a client component.
Checkout
POST /api/stripe/create-checkout creates a Stripe Checkout Session and returns its hosted URL. The route:
- Requires an authenticated session (401 otherwise).
- Blocks the action while an admin is impersonating a user (403).
- Looks up the user's
stripeCustomerId, creating a Stripe customer (withmetadata.userId) and persisting the id if one doesn't exist yet. - Creates the checkout session and returns
{ url }.
The request body selects the checkout type:
type | Mode | Price env var | Result |
|---|---|---|---|
"subscription" (default) | subscription | STRIPE_PRICE_ID | Recurring subscription; on success sets plan = "pro" |
"lifetime" | payment | STRIPE_LIFETIME_PRICE_ID | One-time payment; on success sets plan = "lifetime" |
Both sessions enable promotion codes, use ${APP_URL}/app?upgraded=1 as the success URL, and ${APP_URL}/app as the cancel URL. If the relevant price env var is unset, the route returns a 500 ("Checkout not configured").
Triggering checkout from the frontend
const res = await fetch("/api/stripe/create-checkout", { method: "POST" });
const { url } = await res.json();
window.location.href = url; // redirect to Stripe hosted checkoutFor the lifetime deal, send a JSON body:
const res = await fetch("/api/stripe/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "lifetime" }),
});Customer portal
GET /api/stripe/portal returns { url } for a Stripe billing portal session, where users can update payment methods, view invoices, and cancel. The route requires an authenticated session, blocks impersonation, and returns 404 if the user has no stripeCustomerId yet (i.e. they've never checked out). The portal returns the user to ${APP_URL}/app.
const res = await fetch("/api/stripe/portal");
const { url } = await res.json();
window.location.href = url;Reading the user's plan
There are two ways to know a user's plan, depending on where you are.
In an API route or server code, read it straight off the session:
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user.plan !== "pro") {
return new Response("Upgrade required", { status: 403 });
}From the client, call the status endpoint:
const res = await fetch("/api/stripe/status");
const { plan, status } = await res.json();GET /api/stripe/status returns { plan, status }. Note that plan comes from getEffectivePlan() in lib/admin.ts, so it reflects any admin-granted plan override in addition to the user's paid plan — see Admin. status is the raw subscriptionStatus column (defaults to "inactive").
Webhook: keeping plans in sync
POST /api/stripe/webhook is the source of truth for plan changes. It verifies the Stripe signature using STRIPE_WEBHOOK_SECRET (rejecting unsigned or tampered requests with a 400) and handles these events:
| Event | Effect on the user |
|---|---|
checkout.session.completed (mode payment) | plan = "lifetime", subscriptionStatus = "active" |
checkout.session.completed (mode subscription) | plan = "pro", subscriptionStatus = "active", stores stripeSubscriptionId |
customer.subscription.updated | subscriptionStatus set to the Stripe subscription's status |
customer.subscription.deleted | Reverts to plan = "free", subscriptionStatus = "inactive", clears stripeSubscriptionId — unless the user is on lifetime, in which case they are never downgraded |
invoice.payment_failed | subscriptionStatus = "past_due" |
Each handler matches the user by stripeCustomerId, fires the matching transactional email (subscription confirmation, lifetime purchase, cancellation, or payment-failed — see Email), records an audit log entry, and updates marketing status. Unhandled event types are ignored.
The webhook must receive the raw request body to verify the signature — the route reads
request.text()before parsing. Don't put body-parsing middleware in front of it.
Registering the webhook
Point a Stripe webhook endpoint at https://<your-domain>/api/stripe/webhook and copy its signing secret into STRIPE_WEBHOOK_SECRET. In local dev, use the Stripe CLI to forward events:
stripe listen --forward-to localhost:3000/api/stripe/webhookThe sync-stripe-status cron
Webhooks are the primary sync mechanism, but Valcraven also runs a scheduled backstop. sync-stripe-status runs hourly (0 * * * * in wrangler.toml's [triggers]), iterates every user with a stripeCustomerId, fetches their latest subscription from Stripe, and writes subscriptionStatus. If STRIPE_SECRET_KEY isn't set, the job logs and skips — it never throws.
The job is registered in jobs/index.ts and mapped to its cron expression in lib/scheduled.ts. On Cloudflare Workers, cron triggers fire the scheduled() handler (in worker.js), which requires the CRON_SECRET secret to run. See Cloudflare Workers.
Environment variables
| Variable | Required for | Description |
|---|---|---|
STRIPE_SECRET_KEY | All Stripe features | Secret API key (sk_live_… / sk_test_…) |
STRIPE_PRICE_ID | Subscription checkout | Recurring subscription price (price_…) |
STRIPE_LIFETIME_PRICE_ID | Lifetime checkout (optional) | One-time lifetime price (price_…) |
STRIPE_WEBHOOK_SECRET | Webhook | Signing secret (whsec_…) for signature verification |
APP_URL | Redirects | Base URL for checkout success/cancel and portal return |
See Secrets & env for setting these on Cloudflare, and Environment variables for the full list.
Extending billing
- More plans/tiers. Add prices in Stripe, branch on
type(or apriceId) increate-checkout, and map the resultingplanvalue in the webhook'scheckout.session.completedhandler. - Gate features by plan. Read
session.user.planserver-side, or callgetEffectivePlan()when you want admin overrides to count. Never trust a client-supplied plan. - Admin overrides. Admins can grant a plan without a Stripe charge via plan overrides;
getEffectivePlan()already merges these in, which is why/api/stripe/statusreports the effective plan.
Gotchas
- Impersonation blocks billing. Both checkout and the portal return 403 while an admin is impersonating, so you can't accidentally start a charge on someone else's account.
- Lifetime users are protected on cancellation. The
customer.subscription.deletedhandler explicitly skips lifetime users so a stray subscription-cancel event can't strip their access. planvs.subscriptionStatus.planis the entitlement (free/pro/lifetime);subscriptionStatusmirrors Stripe (active,past_due,inactive, …). Gate features onplan; surfacesubscriptionStatusfor billing UI.