✒️ Becky Isjwara

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

  1. Sign up at stripe.com
  2. Stay in Test mode (toggle top-right)
  3. Get your test keys: Developers → API keys
    • Publishable key (client-side, starts with pk_test_)
    • Secret key (server-side, starts with sk_test_)
  4. 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 stripe

Create 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/stripe

The 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 — succeeds
  • 4000 0000 0000 9995 — fails (insufficient funds)
  • 4000 0027 6000 3184 — requires 3D Secure

Any future expiry, any CVC, any ZIP works.

Going live

  1. In the dashboard, toggle from Test mode to Live mode
  2. Swap your API keys to live keys (sk_live_, pk_live_)
  3. Create a real webhook endpoint in Developers → Webhooks
  4. Recreate your products in live mode (prices don't carry over)
  5. 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, not checkout.session.completed. Handle both.
  • Failed payments don't cancel instantly. Stripe retries for a week by default. Listen to customer.subscription.updated with status: '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.url

Related

Combine with Supabase to link Stripe customers to your users table. The Vercel guide covers deploying your webhook endpoint.

Rather skip the DIY?

I'll set this up for you in an afternoon.