CoreMVP

Payments

Stripe integration for lifetime purchases and subscriptions.

Overview

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.

Creating Checkout Sessions

The billing service creates checkout sessions for both one-time and recurring payments:

src/services/billing/service.ts
import 'server-only';

import { stripe } from '@/utils/stripe/config';
import { createSupabaseServerClient } from '@/lib/supabase/server';

export async function checkoutWithStripe(
  priceId: string,
  redirectPath: string = '/account',
): Promise<CheckoutResponse> {
  const supabase = await createSupabaseServerClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    throw new Error('Could not get user session.');
  }

  // Get price from database
  const priceData = await getPriceById(supabase, priceId);


  // Create or retrieve Stripe customer
  const customer = await createOrRetrieveCustomer({
    uuid: user.id,
    email: user.email ?? '',
  });

  // Build checkout params
  let params: Stripe.Checkout.SessionCreateParams = {
    customer,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: getURL(redirectPath),
    cancel_url: getURL(),
  };

  // Set mode based on price type
  if (priceData.type === 'recurring') {
    params.mode = 'subscription';
  } else {
    params.mode = 'payment';
  }

  const session = await stripe.checkout.sessions.create(params);
  return { sessionId: session.id, checkoutUrl: session.url };
}

API Routes

The checkout Hono route:

src/api/routes/billing.ts
import { Hono } from 'hono';
import { checkoutWithStripe } from '@/services/billing';

export const billingRoutes = new Hono();

billingRoutes.post('/checkout', async (c) => {
  const body = await c.req.json();
  const result = await checkoutWithStripe(body.priceId, body.redirectPath);
  return c.json(result, result.errorRedirect ? 400 : 200);
});

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

Let users manage their subscriptions:

src/services/billing/service.ts
export async function createStripePortal(currentPath: string): Promise<StripePortalResponse> {
  const supabase = await createSupabaseServerClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  const customer = await createOrRetrieveCustomer({
    uuid: user.id,
    email: user.email ?? '',
  });

  const { url } = await stripe.billingPortal.sessions.create({
    customer,
    return_url: getURL(currentPath || '/dashboard'),
  });

  return { url };
}

Client-Side Usage

Use the checkout hook:

src/hooks/billing/use-checkout.ts
export function useCheckout() {
  return useMutation({
    mutationFn: async ({ priceId }: { priceId: string }) => {
      const res = await fetch('/api/billing/checkout', {
        method: 'POST',
        body: JSON.stringify({ priceId }),
      });
      return res.json();
    },
    onSuccess: (data) => {
      if (data.checkoutUrl) {
        window.location.href = data.checkoutUrl;
      }
    },
  });
}

In components:

const { mutate: checkout, isPending } = useCheckout();

<Button onClick={() => checkout({ priceId: 'price_xxx' })}>
  {isPending ? 'Loading...' : 'Subscribe'}
</Button>

Stripe Setup

Create Products

In Stripe Dashboard, create products with prices:

  • Lifetime: One-time price
  • Monthly: Recurring monthly
  • Yearly: Recurring yearly

Configure Webhook

Add webhook endpoint in Stripe Dashboard:

  • URL: https://your-domain.com/api/webhooks/stripe
  • Events:
    • product.created
    • product.updated
    • product.deleted
    • price.created
    • price.updated
    • price.deleted
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted

Add Keys

For local billing tests, add Stripe test-mode keys to .env.local. Start the Stripe CLI listener, then copy its displayed test-mode signing secret into the same ignored file:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Use live-mode keys only in the hosted production environment after the complete test-mode path passes. Do not mix test products or cards with 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

Copy the resulting product and price IDs into src/config/pricing.config.ts. The configured IDs must belong to the same Stripe test account as STRIPE_SECRET_KEY; the example IDs are not portable between Stripe accounts.

Then run the template-local browser proof:

./coremvp e2e billing:lifetime
./coremvp e2e billing:subscription

Was this page helpful?

On this page