Payments
Stripe and Polar lifetime and recurring billing.
Overview
Stripe is the default provider. To use Polar for new lifetime and recurring purchases, follow Polar Billing. Existing billing relationships keep their own provider and management actions when the selector changes. The Stripe setup below remains available for Stripe checkout and retained Stripe webhooks and customer portals.
Payments are handled by Stripe with support for:
- Lifetime purchases - One-time payment, permanent access
- Subscriptions - Monthly/yearly recurring billing
- Customer portal - Self-service subscription management
Architecture
Payment Flow
- The user clicks Buy and the frontend calls
/api/billing/checkout. - The Next.js catch-all passes the request to the Hono billing route.
- The billing service creates the Stripe Checkout session.
- After payment, Stripe sends the signed event to
/api/webhooks/stripe. - The Hono webhook route verifies the exact raw body, then the existing webhook service writes the durable purchase or subscription state.
API Routes
The browser sends a source offer to the same-origin route:
{ "tierId": "tier-pro", "billingPeriod": "yearly" }Use tier-individual or tier-enterprise without billingPeriod for lifetime
checkout. The server selects the configured provider and validates its offer;
recurring checkout requires a signed-in account. Legacy approved Stripe price
inputs remain available only when Stripe is selected. See API Routes
for request shapes and mutation requirements.
Start in src/api/routes/billing.ts for validated checkout input. Lifetime
checkout dispatch lives in src/services/billing/polar.ts, recurring dispatch
in src/services/billing/polar-subscriptions.ts, and Stripe session creation
in src/services/billing/service.ts. Extend those owners instead of recreating
checkout from an example.
Webhook Handling
The Hono route owns only raw ingress and the HTTP response. Signature verification happens before the existing webhook service processes or persists the event:
import { Hono } from "hono";
import { stripe } from "@/utils/stripe/config";
import { handleStripeWebhookEvent } from "@/services/billing/webhook";
export const webhookRoutes = new Hono();
webhookRoutes.post("/stripe", async (c) => {
const body = await c.req.raw.text();
const signature = c.req.raw.headers.get("stripe-signature");
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!signature || !webhookSecret) {
return c.text("Webhook secret not found.", 400);
}
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
const result = await handleStripeWebhookEvent(event);
return Response.json(result.body, { status: result.status });
});Customer Portal
The billing page lists stored subscriptions and the provider's management
actions. Send an owned data.managementTargets reference from
GET /api/user/status to the portal route:
const response = await fetch("/api/billing/portal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: managementTarget.id }),
});
const result = await response.json();
if (response.ok && result.url) window.location.href = result.url;The server authenticates the account and resolves its provider customer. It
uses the owned Stripe subscription's actual customer even when a later customer
snapshot differs. Portal creation does not depend on the new-checkout selector,
and the return path is fixed to /dashboard/billing.
Client-Side Usage
Use the shipped hook with a source tier and recurring cadence:
const checkout = useCheckoutMutation();
<Button
onClick={async () => {
const result = await checkout.mutateAsync({
tierId: "tier-pro",
billingPeriod: "monthly",
});
if (result.checkoutUrl) window.location.href = result.checkoutUrl;
else if (result.errorRedirect) window.location.href = result.errorRedirect;
}}
disabled={checkout.isPending}
>
Subscribe
</Button>;Import useCheckoutMutation from @/hooks/billing. Lifetime pricing uses the
same hook with only its source tierId.
Stripe Setup
To remove an unused integration, follow Use One Billing Provider. Changing the checkout provider alone preserves installed readers, portals and webhook handling for existing billing.
Create Products
In Stripe Dashboard, create products with one-time lifetime prices or recurring monthly/yearly prices. The CLI fixture path below creates the shipped example catalog.
Configure Webhook
Add https://your-domain.com/api/webhooks/stripe with these events:
product.created,product.updated,product.deletedprice.created,price.updated,price.deletedcheckout.session.completedinvoice.paidcustomer.subscription.created,customer.subscription.updated,customer.subscription.deleted
Add Keys
For local billing, add Stripe test keys to ignored .env.local. Start the
Stripe CLI listener, then copy its signing secret into the same file:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Use live keys only in the hosted production environment after the complete test path passes. Keep test products and cards separate from live keys.
Testing
Use Stripe's test cards:
| Card | Result |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Declined |
4000 0025 0000 3155 | Requires 3DS |
Forward webhooks locally:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripeCopy the displayed whsec_... value to STRIPE_WEBHOOK_SECRET in ignored
.env.local, keep the listener running, and restart bun run dev so the app
loads it.
From another terminal, create the test-mode catalogs you want to exercise:
stripe fixtures supabase/stripe/stripe-fixtures-lifetime.json
stripe fixtures supabase/stripe/stripe-fixtures-subscription.json
stripe products list --limit 5
stripe prices list --limit 10Set NEXT_PUBLIC_STRIPE_MODE=test and the public Product/Price inputs listed in
Environment Variables in ignored
.env.local, then restart the app. The IDs must belong to the same Stripe test
account as STRIPE_SECRET_KEY. Checkout stays unavailable for any test Price ID
you have not configured; the template supplies no fallback catalog.
Keep displayed amounts and features in src/config/pricing.config.ts aligned
with Stripe.
For the lifetime guest journey, set EMAIL_DELIVERY_MODE=test_capture and a
server-only TEST_EMAIL_CAPTURE_TOKEN in ignored .env.local, then restart the
app. The generated Stripe test buyer needs no inbox. Configure the Stripe
customer portal before running the suite.
The lifetime suite checks signed-in and guest payment, password setup, stored lifetime access, Billing and the owned portal, protected HTML/Markdown, and signout denial. Guest browser captures are disabled to protect recovery and portal credentials.
Then run the template-local browser proof:
./coremvp e2e billing:lifetime
./coremvp e2e billing:subscriptionRelated
- Authentication - Connect users to purchases
- Content Gating - Gate content by purchase
- Database - Subscription data storage
Was this page helpful?