Back to Knowledge
Updated Sep 2026
GuideMoney

Payments & Stripe

Accept payments, sell subscriptions, and get paid. The boring-but-bulletproof recipe: Stripe Checkout for the payment page, the Customer Portal for self-service, and verified webhooks as the source of truth.

Why Stripe?

Stripe handles the scary stuff: PCI compliance, fraud detection, global payment methods, tax, and invoicing. You focus on your product. With hosted Checkout, card numbers never touch your server.

PCI Handled

You never touch card data

Global

Many currencies and payment methods

Subscriptions

Built-in recurring billing

Invoices & Tax

Stripe Tax can calculate it for you

Pricing: you mostly pay per successful transaction, and the rate varies by country and payment method. Check stripe.com/pricing for your region before you set your prices.

Pick Your Integration

One-Time Payments

Customer pays once, gets the thing. Digital downloads, lifetime deals, e-commerce.

Use: Checkout with mode: "payment"

Subscriptions

Recurring billing. Monthly or yearly plans for SaaS, memberships, newsletters.

Use: Checkout with mode: "subscription" + Customer Portal

Checkout (recommended)

Stripe-hosted payment page. Least code, handles wallets, 3D Secure, and tax.

Payment Links

No code at all. Create a link in the Dashboard. Great for validating demand.

Elements

Embed payment fields in your own UI. More control, more code. Graduate here later.

Quick Setup: Stripe Checkout in Next.js

Stripe hosts the payment page; your server creates a Checkout Session and redirects the user to it.

1

Create an account, a sandbox, and a product

Sign up at stripe.com. Work in a sandbox (Stripe's isolated test environment). Create a product with a price in the Dashboard and copy its Price ID (price_...). Grab your API keys

API Key

A unique code that identifies you when using an API. It's how services know who's making requests (and who to bill).

"Like a VIP pass. It proves you're allowed in and tracks your usage."

from the API keys page.

2

Add keys to .env.local

.env.local

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...   # from "stripe listen" (see Testing below)
STRIPE_PRICE_PRO=price_...
3

Install and create a server-only client

Terminal

npm install stripe server-only

lib/stripe.ts

import "server-only";
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
4

Create the Checkout Session route

app/api/checkout/route.ts

import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const origin = req.headers.get("origin") ?? "http://localhost:3000";

  const session = await stripe.checkout.sessions.create({
    mode: "payment", // or "subscription"
    line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
    // Tip: pass customer_email or client_reference_id (your user ID)
    // so the webhook knows who paid.
    success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${origin}/pricing`,
  });

  return NextResponse.redirect(session.url!, 303);
}

No payment_method_types needed: Checkout shows cards and other methods you enable in the Dashboard automatically.

5

Add a checkout button

A plain form works in a Server Component

Server Component

A React component that runs ONLY on the server. Can directly access databases, fetch data, and keeps secrets safe. The default in Next.js App Router.

"Like the kitchen in a restaurant. Customers never see it, but that's where the magic happens."

. No client JavaScript needed:

app/pricing/page.tsx

export default function Pricing() {
  return (
    <form action="/api/checkout" method="POST">
      <button type="submit">Upgrade to Pro</button>
    </form>
  );
}

Webhooks: The Critical Part

Don't skip this!

Users can close the tab before reaching your success page. A webhook

Webhook

An automatic message sent from one app to another when something happens. Like 'Hey, a user signed up!' in real-time.

"Like a doorbell. Instead of constantly checking, you get notified when someone arrives."

is how Stripe tells your server "payment succeeded" reliably. Grant access there, never on the redirect.

In a Next.js route handler, read the raw body with await req.text() and verify the signature before trusting anything:

app/api/webhooks/stripe/route.ts

import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const body = await req.text(); // raw body: required for verification
  const sig = req.headers.get("stripe-signature");

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    // Grant access / fulfill the order. Make this idempotent:
    // Stripe can deliver the same event more than once.
  }

  return new Response("OK", { status: 200 });
}

Retries, duplicate events, event ordering, and a full idempotent handler are in the Webhooks guide.

Subscriptions in Three Moves

1

Create a recurring price

Dashboard → Product catalog → add a product with a recurring (monthly or yearly) price.

2

Use mode: "subscription" in Checkout

Same route as above, recurring Price ID. Checkout creates the Customer and Subscription for you.

3

Turn on the Customer Portal

Configure it under Dashboard settings → Billing → Customer portal. Users cancel, switch plans, and update cards without you building any UI.

Full subscriptions guide

Common Pitfalls

Fulfilling on the success page

Relying on the success_url redirect means missed orders when users close the tab, and free stuff for anyone who guesses the URL.

Fix: Fulfill in a verified webhook. The success page is just for a nice "thanks!" message.

Trusting the price from the browser

If your checkout route reads the amount from the request body, anyone can pay $0.01 for your Pro plan.

Fix: Keep prices on the server: look up a Stripe Price ID by plan name, never accept an amount from the client.

Sandbox vs. live mode confusion

Test keys in production = no real money. Live keys in dev = real charges on your own card.

Fix: Use sk_test_ keys in .env.local and Preview env vars; sk_live_ only in Vercel's Production env vars. Each has its own webhook signing secret too.

Double charges on retries

A network blip makes your code retry a create call, and Stripe creates two of the thing.

Fix: Pass an idempotency key on POST requests you might retry: stripe.customers.create(params, { idempotencyKey }). Stripe returns the first result for repeats of the same key.

Not handling failed payments

Cards expire. If you don't react, users keep access they stopped paying for.

Fix: Listen for invoice.payment_failed and subscription status changes. See the subscriptions guide.

Exposing the secret key

An sk_ key in client-side code means anyone can issue refunds, read customers, or create charges on your account.

Fix: Secret key stays on the server (add import "server-only" to the file that uses it). Only the publishable key may use the NEXT_PUBLIC_ prefix.

Testing Payments

In a sandbox, use these test cards (any future expiry, any 3-digit CVC):

4242 4242 4242 4242Success
4000 0000 0000 9995Declined (insufficient funds)
4000 0025 0000 3155Requires 3D Secure authentication

Test webhooks locally with the Stripe CLI:

Terminal

# Install the Stripe CLI (other options: docs.stripe.com/cli/install)
npm install -g @stripe/cli
stripe login

# Forward events to your local route; copy the whsec_... it prints
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# In another terminal, fire a test event
stripe trigger checkout.session.completed

You're ready to monetize!

Lock down the webhook, then add recurring plans.