Stripe payments in Node.js: Checkout and webhooks

I have seen this exact Stripe bug twice: The dangerous part of Stripe in Node.js is not creating a Checkout Session. That part is usually ten lines. The dangerous part is what happens after the customer pays: webhook verification fails because Express parsed the body, the fulfillment code runs twice because Stripe retried an event, and the database says “paid” while the product never ships.

This is the Stripe payments in Node.js setup I ship for small SaaS and paid-tool projects: Checkout for the hosted payment page, webhooks as the source of truth, idempotent fulfillment, and a local test loop with the Stripe CLI before production keys touch the app.

TL;DR

Create Checkout Sessions from your server, never from trusted client prices. Verify Stripe webhooks with the raw request body. Treat webhook events as at-least-once delivery. Store processed event IDs. Fulfill from checkout.session.completed or subscription invoice events, not from the success redirect.

Webhook retries are normal, so fulfillment needs a processed-events table.

The stack I tested after the webhook broke

Node.js 24 LTS runtime; Node 22 remains a maintenance-LTS fallback if your platform has not moved yet

Express 5 style routing

stripe official Node SDK

PostgreSQL for orders and processed events

Stripe Checkout Sessions and Stripe CLI

The shape that does not hurt later

I keep four boundaries clear:

Layer | Owns | Does not own
Client | Plan choice, redirect to Checkout | Price, payment state, fulfillment
API | Create Checkout Session from server-side price IDs | Marking order paid from redirect alone
Webhook | Verify event, update payment state, trigger fulfillment | Trusting unsigned JSON
Database | Order state, Stripe IDs, processed event IDs | Only storing “success=true” from query string

The official Stripe Checkout quickstart is the right baseline. The production change is that I always model state in my database before redirecting.

The redirect is UI; verified webhooks own durable payment state.

The Stripe config that fails quietly when env vars drift

npm install stripe express dotenv
npm install -D @types/express typescript tsx

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Pin the API version in your Stripe Dashboard or webhook endpoint settings,
// then test this service against that exact event shape before deploy.
});

If your account uses a newer pinned API version, use the version Stripe shows in your dashboard or webhook endpoint settings and test webhooks against it. The exact value matters less than pinning and testing it. Surprise API-version drift is not a payment strategy.

The Checkout Session is not the source of truth

The client sends a plan key. The server maps that plan key to a Stripe Price ID. Do not let the browser submit an amount.

import express from "express";
import { stripe } from "./stripe";

const app = express();

app.use((req, res, next) => {
if (req.originalUrl === "/stripe/webhook") return next();
express.json()(req, res, next);
});

const prices = {
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
} as const;

app.post("/billing/checkout", async (req, res) => {
const user = req.user; // from your auth middleware
const plan = req.body.plan as keyof typeof prices;
const price = prices[plan];

if (!price) return res.status(400).json({ error: "Unknown plan" });

const order = await db.orders.create({
userId: user.id,
plan,
status: "pending",
});

const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: user.email,
client_reference_id: order.id,
line_items: [{ price, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing/cancel`,
metadata: {
orderId: order.id,
userId: user.id,
plan,
},
}, {
idempotencyKey: `checkout:${order.id}`,
});

await db.orders.update(order.id, {
stripeCheckoutSessionId: session.id,
});

res.json({ url: session.url });
});

The Stripe idempotency docs are not decorative. If the browser retries, the mobile network drops, or your API gateway repeats the request, you want the same checkout creation result for the same order.

Webhook route: raw body or it fails

This is the bug I see most often. Stripe signs the raw payload. If express.json() parses it before verification, signature checks fail.

app.post(
"/stripe/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];

if (!signature) {
return res.status(400).send("Missing stripe-signature");
}

let event: Stripe.Event;

try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error({ err }, "Stripe webhook signature failed");
return res.status(400).send("Invalid signature");
}

await handleStripeEvent(event);
res.json({ received: true });
}
);

The official Stripe webhook docs frame webhooks as event delivery, not a one-time callback. That matters: your handler must survive retries and out-of-order events.

Idempotent fulfillment

Stripe can deliver the same event more than once. Store the event ID before fulfillment and make the operation transactional.

async function handleStripeEvent(event: Stripe.Event) {
await db.transaction(async (tx) => {
const alreadyProcessed = await tx.processedStripeEvents.find(event.id);
if (alreadyProcessed) return;

await tx.processedStripeEvents.insert({
id: event.id,
type: event.type,
createdAt: new Date(),
});

if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId ?? session.client_reference_id;
if (!orderId) throw new Error("Checkout Session missing order id");

await tx.orders.update(orderId, {
status: "paid",
stripeCustomerId: String(session.customer ?? ""),
stripeSubscriptionId: String(session.subscription ?? ""),
});

const userId = session.metadata?.userId;
const plan = session.metadata?.plan;
if (!userId || !plan) {
throw new Error("Checkout Session missing entitlement metadata");
}

await tx.entitlements.upsert({
userId,
plan,
status: "active",
});
}
});
}

For subscriptions, I also listen to invoice and subscription lifecycle events. Checkout confirms the first purchase path. Ongoing access should follow billing state, not a stale local flag. The processed-events table needs a unique constraint on Stripe event ID; application checks alone are not enough under retries or parallel workers.

The local webhook test that catches raw-body mistakes

stripe login
stripe listen --forward-to localhost:3000/stripe/webhook
stripe trigger checkout.session.completed

The CLI gives you the webhook signing secret for the local listener. Use that value as STRIPE_WEBHOOK_SECRET in local development. Production has its own endpoint and secret.

The payment security checklist I do not skip

Map plan keys to server-side Stripe Price IDs.

Verify every webhook signature with the raw body.

Store processed event IDs.

Keep order/payment state in the database.

Use HTTPS and real environment separation.

Do not log card data, tokens, or full customer payloads.

Reconcile daily against Stripe if payments drive access or shipping.

Rate limit checkout creation; the Express rate limiting guide covers the API side.

The events I wire first

Stripe’s Checkout docs are clear that the success page is not fulfillment. The Checkout quickstart sends the customer back to your app, but the durable state change belongs behind a verified webhook.

Event | Use it for | Do not use it for
checkout.session.completed | Initial order/subscription confirmation | Long-term subscription health by itself
invoice.paid | Renewal access, billing-cycle confirmation | Shipping physical goods without order checks
invoice.payment_failed | Dunning, grace period, access warnings | Instant destructive account deletion
customer.subscription.deleted | Cancel access after subscription ends | Refund logic without checking payment records

The idempotency docs solve API retries. A processed-events table solves webhook retries. I use both because they protect different sides of the payment flow.

Database state I want before launch

orders: local order ID, user ID, plan, status, Stripe Checkout Session ID.

billing_customers: user ID, Stripe customer ID, active subscription ID.

processed_stripe_events: event ID, type, processed timestamp.

entitlements: user ID, feature/plan, status, current period end.

This keeps billing out of auth middleware. The auth side belongs in the JWT authentication guide; payment state should be a separate entitlement check. For noisy checkout attempts, add the guardrails from the Express rate limiting guide. For async emails, invoice sync, or entitlement repair, use the BullMQ queue pattern.

The webhook contract I want in code review

A Stripe webhook handler has one job: turn a verified event into an idempotent local state transition. It should not trust the success redirect, it should not assume events arrive once, and it should not let a duplicate event ship a second order or grant access twice.

Verify the signature against the raw request body before parsing JSON.

Store the Stripe event ID before doing expensive work, and push slow fulfillment into a queue after the state transition so the webhook can return a 2xx quickly.

Make fulfillment idempotent at the database level, not only in application memory.

Fetch the Checkout Session or subscription again when the local state needs more certainty.

Log local order ID, Stripe customer ID, session ID, and event ID together.

The Stripe webhooks docs and Checkout fulfillment guidance both push the same lesson: fulfillment belongs behind verified server-side events. I also schedule a small reconciliation job for paid products. It compares local active entitlements against Stripe invoices/subscriptions and flags drift before a customer finds it.

FAQ

Can I mark an order paid from the success URL? No. The success URL is a user redirect. Use it for UI. Use verified webhooks for payment state and fulfillment.

Why does Stripe webhook verification fail in Express? The usual cause is express.json() running before stripe.webhooks.constructEvent(). Stripe needs the raw request body.

Do I need idempotency keys with Stripe Checkout? Yes for your create-session endpoint, and you also need webhook idempotency by storing processed event IDs.

Should I use Checkout or Payment Intents directly? Use Checkout unless you need a fully custom payment UI. Checkout gets taxes, payment methods, authentication, and hosted UX out of your app.

Where should I store Stripe customer IDs? Store them on your account/user billing record after a verified webhook or confirmed server fetch, not only from client-submitted data.

When Stripe Checkout is the wrong fit

For most Node.js products, I ship Checkout first. It is less glamorous than building a custom card form, but it reduces payment surface area. The engineering work belongs in webhooks, idempotency, reconciliation, and access state. That is where payment bugs become real money.