PaymentsIntermediate~an afternoon

Accept payments

Let users pay you money — a one-time purchase, and the mechanics you need so paid access actually gets granted reliably. Get this wrong and you either leak product to people who never paid or fail to unlock it for people who did.

the approach

Do not build a card form or touch raw card numbers — that drags you into PCI compliance you don't want. Use a hosted checkout (Stripe Checkout is the default) that holds the card and redirects the user back when done. The single most important idea: the browser redirect after payment is NOT proof of payment. Treat Stripe's server-to-server webhook as the source of truth — verify its signature, and only grant access when the verified event says the payment completed. If you sell globally and don't want to deal with VAT/sales-tax registration, reach for a Merchant of Record (Lemon Squeezy, Polar, or Paddle) instead of raw Stripe — they collect and remit tax for you at the cost of a slightly higher fee.

Step by step

  1. 1

    Choose Stripe direct vs a Merchant of Record

    If you're selling to customers in one country or don't mind handling tax later, use Stripe directly — most control, lowest fees. If you're a solo dev selling software worldwide and don't want to register for VAT/sales tax in dozens of jurisdictions, use a Merchant of Record like Lemon Squeezy, Polar, or Paddle; they become the seller of record and remit tax for you.

  2. 2

    Create your product and get test-mode keys

    In the Stripe dashboard, create a Product with a Price (amounts are in the smallest currency unit — 500 = $5.00). Grab your test-mode secret key and publishable key. Stay in test mode until the whole flow works end to end.

  3. 3

    Create a Checkout Session on your server and redirect

    From a server route (never the browser — your secret key must stay server-side), call stripe.checkout.sessions.create with the price, mode: 'payment', success_url, cancel_url, and client_reference_id set to your internal user id so you can tie the payment back to the account. Redirect the user to session.url.

  4. 4

    Handle the webhook as the source of truth

    Add a webhook route that reads the RAW request body, verifies the Stripe signature, and on checkout.session.completed grants access / fulfills the order. Never grant access from the success_url redirect alone — users close tabs, and the URL can be forged.

  5. 5

    Make fulfillment idempotent

    Stripe retries webhooks and can send the same event more than once. Dedupe on the event id or the checkout session id (e.g. an INSERT that ignores duplicates, or a check-before-grant) so a customer never gets charged-once-but-provisioned-twice or double-shipped.

  6. 6

    Test locally with the Stripe CLI

    Run `stripe listen --forward-to localhost:3000/api/stripe/webhook` to pipe real test events to your machine and get the webhook signing secret. Pay with test card 4242 4242 4242 4242 (any future expiry, any CVC). Use 4000 0000 0000 9995 to test a declined card.

  7. 7

    Go live

    Swap test keys for live keys, register your production webhook endpoint in the dashboard (it has its own signing secret), and decide how you'll handle refunds and failed payments. If using Stripe directly, turn on Stripe Tax or accept that tax is now your responsibility.

Starter snippet

typescript
// app/api/stripe/webhook/route.ts  (Next.js App Router, Node runtime)
import Stripe from "stripe";

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

export async function POST(req: Request) {
  const body = await req.text();                 // RAW body — required to verify
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    // Bad signature = not really from Stripe. Reject.
    return new Response(`Invalid signature: ${(err as Error).message}`, { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    // Idempotent: safe to call twice with the same session.id
    await grantAccess(session.client_reference_id!, session.id);
  }

  return new Response(null, { status: 200 }); // 200 tells Stripe to stop retrying
}

✕ Watch out for

  • Granting access on the success_url redirect instead of the webhook. The redirect fires in the user's browser, can be closed before it loads, and can be hit directly with no payment. Only the verified webhook proves money moved.
  • Verifying the signature against a parsed JSON body. constructEvent needs the exact raw bytes — call req.text(), not req.json(). Any framework middleware that pre-parses the body will silently break signature verification.
  • Non-idempotent fulfillment. Stripe redelivers events; without deduping on event/session id you double-provision, double-email, or double-ship.
  • Touching raw card numbers or building your own card <input>. That pulls you into full PCI-DSS scope. Let Stripe's hosted Checkout (or Stripe Elements) hold the card so the number never hits your server.
  • Ignoring sales tax / VAT. Selling globally on raw Stripe makes tax registration and remittance your problem. Use Stripe Tax or hand it off to a Merchant of Record.

✓ Pro tips

  • When you prompt an AI agent, be explicit: 'Use Stripe hosted Checkout plus a webhook. The webhook is the source of truth — read the raw body with req.text(), verify the signature with constructEvent, and make fulfillment idempotent. Do NOT grant access from success_url.' Agents default to the naive redirect-grants-access version, so state this or it'll ship a hole.
  • Then check the generated code for three things: it reads req.text() (not req.json()) before constructEvent, it returns 400 on signature failure, and the access-granting call is safe to run twice. If any is missing, push back.
  • Keep money as integers in the smallest currency unit (cents) and never do floating-point math on amounts — 0.1 + 0.2 problems become real charges.
  • Pin your Stripe API version (in the constructor and in the dashboard webhook config) so a future Stripe update doesn't reshape event payloads under you.