Stripe
Howrekkal integrates Stripe for payments: the server client, the webhook Route Handler, and plan-to-price-ID mapping.
Environment variables
Stripe reads four variables (all listed in.env.example):
STRIPE_SECRET_KEY— secret API key. Server-only; never import into a"use client"module.STRIPE_WEBHOOK_SECRET— signing secret used to verify incoming webhook events.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY— publishable key, safe to expose to the browser. Reserved for client-side Stripe.js/Elements; the current server-side redirect integration never reads it.NEXT_PUBLIC_STRIPE_PRICE_PRO— the Stripe Price ID backing the Pro plan (consumed inconfig.ts).
The server client — lib/stripe.ts
getStripe() in lib/stripe.ts returns a memoized Stripe instance, constructed lazily on first use from STRIPE_SECRET_KEY. Deferring construction (rather than building at module load) lets the app build without the key present — it is only required at request time. The apiVersion is pinned so upgrading the Stripe SDK never silently changes API behavior; bump it deliberately.
Checkout and portal — app/actions/billing.ts
Two server actions drive billing from inside the app:
createCheckoutSession(priceId)— creates amode: "subscription"Checkout Session for the given price and redirects the user to Stripe’s hosted Checkout. It reuses the user’s existing Stripe customer when one is on file, and stamps the user id into both session andsubscription_datametadata so the webhook can attribute events back to the user. Following Stripe best practice, it does not passpayment_method_types(dynamic payment methods are chosen from the Dashboard). Wired to the pricing CTA via the clientButtonCheckout.createBillingPortalSession()— opens the Stripe Billing Portal for the user’s customer so they can upgrade, downgrade, cancel, or update payment methods. Wired toButtonPortalon the account page (/dashboard/settings), which is also where both Stripe redirects land — see dashboard.md.
The webhook — app/api/webhooks/stripe/route.ts
Stripe calls into the app from outside, so it is handled by a Route Handler, not a Server Action. app/api/webhooks/stripe/route.ts exports a POST handler that:
- Reads the raw request body with
request.text()— Stripe signs the exact bytes, so the body must not be parsed or re-serialized before verification. - Verifies the
stripe-signatureheader againstSTRIPE_WEBHOOK_SECRETviaconstructEvent, returning400if verification fails. - Dispatches on
event.type(checkout.session.completed,customer.subscription.updated,customer.subscription.deleted) and upserts the user’ssubscriptionsrow via the service-role Supabase client (which bypasses RLS), returning200with{ received: true }.
user_id, so re-delivered events converge to the same row. The pure Stripe→row mapping lives in lib/subscriptions.ts and is unit-tested. Note that as of API version 2026-06-24.dahlia, current_period_end is a per-subscription-item field, so the mapping reads it from subscription.items.data[0]. On a successful checkout the handler also sends a best-effort “subscription active” email (see email.md).
Plans and price IDs — config.ts
Billing plans live in config.ts under config.plans (free and pro). Each plan carries its stripePriceId: the free tier is null (not billed through Stripe), and the Pro plan reads NEXT_PUBLIC_STRIPE_PRICE_PRO so the ID differs between test and live Stripe accounts. Import plan data from config.ts rather than duplicating price IDs across the codebase. Displayed prices are formatted in config.currency, which must match the currency the Stripe prices were created in — see its doc comment in config.ts.
Local webhook testing
Point a Stripe webhook endpoint at/api/webhooks/stripe. For production this is a Vercel URL (see deployment.md); locally, forward events with the Stripe CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe), which prints the STRIPE_WEBHOOK_SECRET to use.
Further reading
The Stripe skills (stripe-docs, stripe-best-practices, stripe-directory, stripe-projects, connect-recommend, upgrade-stripe) are pre-bundled (see agent-skills.md).