· Venturi Code
If Cursor, Claude Code or Codex wired up your payments, check them against this setup before you scale paid traffic. A checkout that leaks buyers or grants access too early costs you money on every click. Here is the setup that holds up in production, step by step.
1. Decide prices on the server, never in the browser
The browser should only say what the buyer picked (a plan name and a quantity). The server turns that into a Stripe price. If the browser sends an amount, anyone can change it.
2. Create a Checkout session in a route handler
// app/api/checkout/route.ts
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const PRICES = { starter: 'price_123', pro: 'price_456' } as const
export async function POST(request: Request) {
const { plan } = (await request.json()) as { plan: keyof typeof PRICES }
const price = PRICES[plan]
if (!price) return NextResponse.json({ error: 'Unknown plan' }, { status: 400 })
const session = await stripe.checkout.sessions.create({
mode: 'subscription', // or 'payment' for one-time
line_items: [{ price, quantity: 1 }],
success_url: `${process.env.SITE_URL}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/#pricing`,
})
return NextResponse.json({ url: session.url })
}The pricing page calls this route and sends the buyer to session.url. Stripe hosts the payment form, so card details never touch your server.
3. Give access from a verified webhook, not the success page
The success page can be opened by anyone with the link. Grant access only when Stripe tells your server the payment succeeded, and check the signature on the raw request body first.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text() // raw body: the signature is computed over it
const signature = request.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Bad signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
// Grant access here. Stripe retries webhooks, so make this safe to run twice
// (e.g. a unique constraint on the session id).
}
return new Response('ok')
}4. Handle refunds and disputes too
Listen for charge.refunded and charge.dispute.created and remove access when they arrive. Otherwise "buy, use, dispute" is free.
5. Build a pricing page that converts
- Two or three plans with one highlighted
- Each plan described by the outcome, not a feature dump
- An annual toggle with the saving shown in money, not just a percentage
- Proof and a guarantee right next to the buttons
- An order bump at checkout if you have a natural add-on
The shortcut
Venturi’s pricing table, billing toggle, cart drawer and order bump cover the pricing page and the order panel. Add them to your app with npx venturi-dev add pricing-table cart-drawer order-bump. Starting a new app? npx venturi-dev init gives you a Next.js and Supabase starter with Stripe Checkout and the webhook from this guide already wired.