Objective
What you're building
Real payments, wired before a single line of AI code exists. This is the deliberately "backwards" move this course makes on purpose: Section 4 will build the AI feature already knowing exactly how it connects to a paid tier, instead of retrofitting monetization onto something built assuming it's free.
Why payments before the fun part
Building the AI feature first tempts you to design around "what's technically interesting," not "what a paying user needs"
If you build proposal generation first and payments last, by the time you reach Stripe you've already made a dozen small decisions (how much context to include, how fast responses need to feel, what the output format is) without ever asking "does this justify $12/month?" Building payments first forces that question to be answered before the feature exists, not after you're emotionally attached to a specific implementation.
Stripe checkout: redirect, don't build a payment form
// app/api/checkout/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { userId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
client_reference_id: userId,
});
return Response.json({ url: session.url });
}
client_reference_id: userId is the detail that connects a Stripe payment back to a specific Supabase user -- without it, the webhook in the next step has no reliable way to know WHO just paid. Stripe Checkout (a hosted page you redirect to) rather than building your own card form is the correct default: PCI compliance, card validation, and Apple Pay/Google Pay support all come free.
Webhooks: signature verification is not optional
An unverified webhook endpoint is a real, exploitable security hole
Anyone who discovers your webhook URL could POST a fake "subscription activated" event and get free access, unless you verify the request actually came from Stripe using the webhook signing secret. This isn't defensive paranoia -- it's the literal difference between "only Stripe can grant subscription access" and "anyone who finds this URL can."
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id;
// update usage_limits: subscription_status = 'active' for this userId
}
return Response.json({ received: true });
}
stripe.webhooks.constructEvent does the actual signature verification -- if it throws, the request wasn't really from Stripe, and you reject it before touching your database. Local testing needs the Stripe CLI's listen command (or ngrok) to forward Stripe's real webhook calls to your local dev server, since Stripe can't reach localhost directly.
You test your Stripe integration locally and payments seem to work in the Stripe dashboard, but your app's database never shows subscription_status as 'active'. What's the most likely cause?
"Payment succeeded in Stripe's dashboard" and "your app's webhook received and processed that event" are two different things -- Stripe's servers can't reach your localhost directly, so without an active stripe listen (or ngrok) forwarding session, the webhook events never arrive, and your database update code never runs, even though the actual payment went through fine on Stripe's side.
What you're building today
/api/checkout (creates a Checkout session with client_reference_id) and /api/webhooks/stripe (signature-verified, updates usage_limits.subscription_status on checkout.session.completed) — tested end-to-end with stripe listen forwarding to your local server and stripe trigger checkout.session.completed, confirmed by checking the database row actually updates.