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
Payment Flow
1. User clicks "Buy" on pricing page
2. Frontend calls /api/billing/checkout
3. API route calls checkoutWithStripe service
4. Service creates Stripe checkout session
5. User completes payment on Stripe
6. Stripe sends webhook to /api/webhooks/stripe
7. Webhook updates user's subscription statusCreating Checkout Sessions
The billing service creates checkout sessions for both one-time and recurring payments:
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 API route:
import { NextResponse } from 'next/server';
import { checkoutWithStripe } from '@/services/billing';
export async function POST(request: Request) {
const body = await request.json();
const result = await checkoutWithStripe(body.priceId, body.redirectPath);
return NextResponse.json(result, {
status: result.errorRedirect ? 400 : 200,
});
}Webhook Handling
Webhooks update your database when Stripe events occur:
import Stripe from 'stripe';
import { stripe } from '@/utils/stripe/config';
import {
manageSubscriptionStatusChange,
upsertProductRecord,
upsertPriceRecord,
} from '@/services/billing/service';
const relevantEvents = new Set([
'product.created',
'product.updated',
'price.created',
'price.updated',
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
]);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature') as string;
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (relevantEvents.has(event.type)) {
switch (event.type) {
case 'product.created':
case 'product.updated':
await upsertProductRecord(event.data.object as Stripe.Product);
break;
case 'price.created':
case 'price.updated':
await upsertPriceRecord(event.data.object as Stripe.Price);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await manageSubscriptionStatusChange(
subscription.id,
subscription.customer as string,
);
break;
}
}
return new Response(JSON.stringify({ received: true }));
}Customer Portal
Let users manage their subscriptions:
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:
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.*,price.*,checkout.session.completed,customer.subscription.*
Add Keys
For local billing tests, add Stripe test-mode keys to .env.local. The
listener writes the active test-mode webhook secret when it starts:
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:
| Card | Result |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Declined |
4000 0025 0000 3155 | Requires 3DS |
Forward webhooks locally:
./coremvp stripe:listenAfter the listener syncs STRIPE_WEBHOOK_SECRET, keep it running and restart
bun run dev so the app loads the active signing secret.
From another terminal, create the test-mode catalogs you want to exercise:
stripe login
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 10Copy 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:subscriptionRelated
- Authentication - Connect users to purchases
- Content Gating - Gate content by purchase
- Database - Subscription data storage
Was this page helpful?