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
- 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.
Creating 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 Hono route:
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:
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:
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.createdproduct.updatedproduct.deletedprice.createdprice.updatedprice.deletedcheckout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.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:
| 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 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?