CoreMVP

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

service.ts
types.ts
webhook.ts
app.ts

Payment Flow

  1. The user clicks Buy and the frontend calls /api/billing/checkout.
  2. The Next.js catch-all passes the request to the Hono billing route.
  3. The billing service creates the Stripe Checkout session.
  4. After payment, Stripe sends the signed event to /api/webhooks/stripe.
  5. 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:

src/api/routes/webhooks.ts
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.deleted
  • price.created, price.updated, price.deleted
  • checkout.session.completed
  • invoice.paid
  • customer.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:

CardResult
4242 4242 4242 4242Success
4000 0000 0000 0002Declined
4000 0025 0000 3155Requires 3DS

Forward webhooks locally:

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Copy 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 10

Set 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:subscription

Was this page helpful?

On this page