Payments · Guide
Stripe — accept payments
The minimum viable setup for a real paid product.
Difficulty
Medium
Setup time
1-2 hours
Cost
2.9% + 30¢ per transaction
Why Stripe?
Stripe is the default for a reason. The API is famously good, the docs are the gold standard, and the dashboard is usable. Alternatives (Paddle, LemonSqueezy) exist mostly to handle VAT/MOSS for you — worth considering if you're selling to Europe.
LemonSqueezy handles the tax mess for you (merchant of record), but takes 5% + $0.50. For most US-based indie projects, Stripe + manual tax handling is fine until you're making real money.
Concepts you need to know
- Checkout Session — the hosted page users pay on. Easiest integration. Stripe handles the form, 3DS, Apple Pay.
- Product & Price — products have prices. A one-time "Pro" purchase is a product with one price. A subscription is a product with a recurring price.
- Webhook — Stripe calls your API when things happen (payment succeeded, subscription renewed, card failed). You store the result in your database.
- Customer — a person's Stripe record. Link this to your users table by storing
stripe_customer_id.
Setup
- Sign up at stripe.com
- Stay in Test mode (toggle top-right)
- Get your test keys: Developers → API keys
Publishable key(client-side, starts withpk_test_)Secret key(server-side, starts withsk_test_)
- Add to
.env.local:STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Create a product
In the dashboard, Products → Add Product. For a subscription:
- Name: e.g. "Pro"
- Pricing model: Standard
- Price: $10 / month (or whatever)
- Save. Copy the Price ID (starts with
price_)
Checkout flow (Next.js)
Install the SDK:
npm install stripeCreate src/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, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: 'price_xxx', quantity: 1 }],
customer_email: email,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId },
});
return Response.json({ url: session.url });
}From your frontend, call this and redirect:
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ userId: user.id, email: user.email }),
});
const { url } = await res.json();
window.location.href = url;Webhooks — the part everyone gets wrong
The checkout succeeds → Stripe redirects to your success URL. But don't grant access based on the redirect — users can fake it. Grant access when the webhook fires.
Create src/app/api/webhooks/stripe/route.ts:
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return new Response(`Webhook error: ${err}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const userId = session.metadata?.userId;
// Mark user as Pro in your database here
}
if (event.type === 'customer.subscription.deleted') {
// Downgrade user
}
return Response.json({ received: true });
}Testing webhooks locally
Install the Stripe CLI. Forward webhooks to your local dev server:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe CLI gives you a webhook secret — put that in .env.local as STRIPE_WEBHOOK_SECRET. When you go to production, generate a new secret in Developers → Webhooks → Add endpoint.
Test cards
4242 4242 4242 4242— succeeds4000 0000 0000 9995— fails (insufficient funds)4000 0027 6000 3184— requires 3D Secure
Any future expiry, any CVC, any ZIP works.
Going live
- In the dashboard, toggle from Test mode to Live mode
- Swap your API keys to live keys (
sk_live_,pk_live_) - Create a real webhook endpoint in Developers → Webhooks
- Recreate your products in live mode (prices don't carry over)
- Do a real $1 test transaction. Refund yourself.
Common gotchas
- Webhook signing secret is environment-specific. Test mode and Live mode have different secrets. Same for the CLI vs production.
- Subscription renewals send
invoice.payment_succeeded, notcheckout.session.completed. Handle both. - Failed payments don't cancel instantly. Stripe retries for a week by default. Listen to
customer.subscription.updatedwithstatus: 'past_due'. - Tax. For US-only, you're mostly fine. For EU/UK sales, either use Stripe Tax ($ per transaction) or switch to LemonSqueezy.
Customer portal (account management)
Stripe has a hosted portal where users update cards, cancel, see invoices. Free. Enable it in Settings → Billing → Customer portal, then:
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/account`,
});
// Redirect user to portalSession.urlRelated
Combine with Supabase to link Stripe customers to your users table. The Vercel guide covers deploying your webhook endpoint.