Introduction
If you've ever added a "Buy Now" button to a web app, you already know the easy part is the button. The hard part is everything behind it — securely charging a card, confirming the money actually landed, handling failed payments, and keeping subscriptions in sync without losing your mind (or your users' trust).
That's where Stripe comes in. It's become the default choice for developers building payment systems in 2026, not because it's the only option, but because it gets out of your way. It handles PCI compliance, fraud detection, currency conversion, and international tax logic, so you can focus on your product instead of reinventing a payment gateway from scratch.
In this guide, we'll build a complete Stripe integration in Node.js, covering three things every SaaS or e-commerce backend eventually needs:
- One-time payments using Payment Intents and Checkout Sessions
- Webhooks to reliably react to payment events
- Subscriptions for recurring billing
By the end, you'll have working code you can drop into a real project — not just theory. Let's get into it.
Why Stripe (and Why Node.js)?
Before writing code, it's worth understanding why this combination is so common.
- Stripe abstracts away the messy parts of payments: card tokenization, 3D Secure authentication, SCA (Strong Customer Authentication) compliance, and dispute handling.
- Node.js pairs naturally with Stripe's event-driven model. Webhooks are asynchronous by nature, and Node's non-blocking I/O makes it easy to handle bursts of payment events without choking your server.
- Stripe's official
stripenpm package is actively maintained and mirrors the REST API almost 1:1, so what you learn here transfers directly to their docs.
If you're building anything from a simple checkout flow to a full subscription-based SaaS product, this stack is a safe, battle-tested choice.
Prerequisites
Before diving in, make sure you have:
- Node.js 18+ installed (Node 20 LTS or later is recommended in 2026)
- A free Stripe account
- Basic familiarity with Express.js
- The Stripe CLI installed for local webhook testing
Once you're signed in to your Stripe Dashboard, grab your test mode API keys from Developers → API keys. Never use live keys during development.
Setting Up the Project
Let's scaffold a minimal Express server.
mkdir stripe-node-demo && cd stripe-node-demo
npm init -y
npm install express stripe dotenv
npm install -D nodemon
Create a .env file to store your secret key — never hardcode it:
# .env
STRIPE_SECRET_KEY=sk_test_yourSecretKeyHere
STRIPE_WEBHOOK_SECRET=whsec_yourWebhookSecretHere
PORT=4242
Add .env to your .gitignore immediately. Leaked Stripe keys are one of the most common security incidents in production apps, and Stripe's own bots scan public GitHub repos for exposed keys within minutes.
Now set up a basic server:
// server.js
require("dotenv").config();
const express = require("express");
const Stripe = require("stripe");
const app = express();
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
app.use(express.json());
app.listen(process.env.PORT, () => {
console.log(`Server running on port ${process.env.PORT}`);
});
That's your foundation. Now let's make it actually charge someone.
Part 1: One-Time Payments
Stripe offers two common approaches for one-time payments: Payment Intents (for custom UIs) and Checkout Sessions (Stripe-hosted, low-code). Most modern apps in 2026 lean toward Checkout Sessions unless they need a fully custom payment form.
Option A: Stripe Checkout (Recommended for Most Apps)
Checkout Sessions redirect the user to a Stripe-hosted payment page. It's fast to implement and handles edge cases (Apple Pay, Google Pay, 3D Secure) automatically.
app.post("/create-checkout-session", async (req, res) => {
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "usd",
product_data: { name: "Premium T-Shirt" },
unit_amount: 2500, // amount in cents ($25.00)
},
quantity: 1,
},
],
success_url: "https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://yourapp.com/cancel",
});
res.json({ url: session.url });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
On the frontend, you simply redirect the user to session.url. No card data ever touches your server — Stripe handles it entirely, which significantly reduces your PCI compliance burden.
Option B: Payment Intents (For Custom Checkout UIs)
If you want a fully embedded payment form (using Stripe Elements), you'll use Payment Intents instead:
app.post("/create-payment-intent", async (req, res) => {
const { amount, currency } = req.body;
try {
const paymentIntent = await stripe.paymentIntents.create({
amount, // e.g., 2500 for $25.00
currency: currency || "usd",
automatic_payment_methods: { enabled: true },
});
res.json({ clientSecret: paymentIntent.client_secret });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
The frontend then uses the returned clientSecret with Stripe.js and Stripe Elements to confirm the payment directly in your own UI. This gives you full design control but requires more frontend work and stricter compliance handling on your end.
Rule of thumb: Use Checkout Sessions unless your product specifically needs a custom, embedded payment experience.
Part 2: Webhooks — The Backbone of Reliable Payments
Here's something a lot of tutorials gloss over: a successful API response doesn't mean the payment actually succeeded. Cards can be declined after authentication, banks can flag transactions, and async payment methods (like bank transfers) can take days to settle.
This is why webhooks exist. Stripe sends your server real-time HTTP notifications whenever something happens — a payment succeeds, a subscription renews, an invoice fails, and so on. Your backend should treat these events, not the initial API response, as the source of truth.
Why Signature Verification Matters
Anyone can send a POST request to a public URL pretending to be Stripe. To prevent spoofed events, Stripe signs every webhook with a secret only you and Stripe know. You must verify this signature using the raw request body — not the parsed JSON.
This is the single most common mistake developers make, so let's do it right.
// IMPORTANT: This route must NOT use express.json() middleware
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
console.log("Checkout completed:", session.id);
// Fulfill the order, update your database, send a confirmation email
break;
}
case "payment_intent.succeeded": {
const intent = event.data.object;
console.log("Payment succeeded:", intent.id);
break;
}
case "payment_intent.payment_failed": {
const intent = event.data.object;
console.warn("Payment failed:", intent.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
}
);
Notice the middleware order carefully: this route uses express.raw() instead of express.json(). If you apply express.json() globally before this route, signature verification will fail because the body will already be parsed and re-serialized, altering the raw bytes Stripe originally signed.
A safe pattern is to register the webhook route before your global JSON middleware:
app.post("/webhook", express.raw({ type: "application/json" }), webhookHandler);
app.use(express.json()); // applies to all routes registered after this line
Testing Webhooks Locally
You don't need to deploy your app to test webhooks. The Stripe CLI can forward events straight to your local server:
stripe login
stripe listen --forward-to localhost:4242/webhook
This command prints a webhook signing secret (whsec_...) — copy it into your .env file. Then trigger test events:
stripe trigger payment_intent.succeeded
Watch your server logs to confirm the event was received and verified correctly.
Part 3: Subscriptions
Subscriptions introduce a few new concepts: Products, Prices, and Customers. Instead of charging an arbitrary amount, you define what you're selling once, then charge customers against that definition repeatedly.
Step 1: Create a Product and Price
You can do this via the Dashboard or programmatically:
const product = await stripe.products.create({
name: "Pro Plan",
description: "Full access to premium features",
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 1999, // $19.99
currency: "usd",
recurring: { interval: "month" },
});
console.log("Price ID:", price.id);
In practice, you'll usually create these once in the Dashboard and reference the price_id in your code, rather than creating them on every request.
Step 2: Create a Customer and Subscribe Them
app.post("/create-subscription", async (req, res) => {
const { email, paymentMethodId, priceId } = req.body;
try {
// Create or retrieve the customer
const customer = await stripe.customers.create({
email,
payment_method: paymentMethodId,
invoice_settings: { default_payment_method: paymentMethodId },
});
// Create the subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
payment_behavior: "default_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
expand: ["latest_invoice.payment_intent"],
});
res.json({
subscriptionId: subscription.id,
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
The payment_behavior: "default_incomplete" setting is important — it lets you confirm the initial payment on the frontend using the returned client_secret, handling any additional authentication (like 3D Secure) gracefully.
Step 3: Handle Subscription Lifecycle Events via Webhooks
Subscriptions live and die by webhook events. At minimum, handle these:
switch (event.type) {
case "invoice.payment_succeeded":
// Renewal succeeded — extend user's access
break;
case "invoice.payment_failed":
// Payment failed — notify user, possibly restrict access
break;
case "customer.subscription.updated":
// Plan changed, or subscription status changed (e.g., trialing → active)
break;
case "customer.subscription.deleted":
// Subscription canceled — revoke access
break;
}
Never rely solely on your database's "subscription active" flag being set once at signup. Subscriptions are living objects — they renew, fail, get upgraded, downgraded, paused, and canceled. Your webhook handler is what keeps your database honest.
Step 4: Let Customers Manage Their Own Billing
Rather than building your own billing UI, Stripe offers a Customer Portal — a hosted page where users can update cards, view invoices, or cancel subscriptions themselves.
app.post("/create-portal-session", async (req, res) => {
const { customerId } = req.body;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "https://yourapp.com/account",
});
res.json({ url: session.url });
});
This alone can save you weeks of frontend development and reduces support tickets significantly.
Best Practices
- Always verify webhook signatures using the raw body — never trust unverified payloads.
- Use idempotency keys on payment creation requests to avoid double-charging if a request is retried:
await stripe.paymentIntents.create( { amount, currency: "usd" }, { idempotencyKey: "unique-order-id-123" } ); - Store Stripe IDs, not raw payment data. Save
customer.id,subscription.id, andpayment_intent.idin your database — never store card numbers. - Handle webhook retries gracefully. Stripe retries failed webhook deliveries; make your handlers idempotent so processing the same event twice doesn't duplicate side effects (like sending two confirmation emails).
- Use test clocks in test mode to simulate subscription renewals and trial expirations without waiting real days.
- Set up monitoring on your webhook endpoint. If it goes down, Stripe will retry for a while, but you can silently miss critical events if downtime is prolonged.
- Version-pin the Stripe API by setting an explicit API version in your Dashboard settings, so a Stripe-side update doesn't unexpectedly change your response shapes.
Common Mistakes to Avoid
- ❌ Parsing the webhook body with
express.json()before verification. This breaks signature validation because Stripe signs the exact raw bytes it sent. - ❌ Trusting the client-side "success" redirect as proof of payment. Always confirm final payment status through webhooks, not just the
success_urlredirect. - ❌ Hardcoding prices in your codebase. Use Stripe's Price objects so you can adjust pricing from the Dashboard without redeploying code.
- ❌ Forgetting to handle
payment_intent.payment_failed. Silent failures lead to confused customers and unnecessary support load. - ❌ Mixing test and live API keys. Double-check your
.envbefore deploying — a live key in a staging environment can cause real charges. - ❌ Not handling subscription cancellations properly. If you don't listen for
customer.subscription.deleted, canceled users may retain paid access indefinitely.
🚀 Pro Tips
- Use
stripe listen --forward-toduring development so you never have to deploy just to test a webhook. - Enable Smart Retries in the Stripe Dashboard for failed subscription payments — Stripe automatically retries at optimal times based on machine learning models, recovering a meaningful chunk of failed renewals.
- Use metadata fields on Stripe objects (
customer,payment_intent,subscription) to store your internal user IDs or order references — it makes reconciling Stripe data with your own database much easier. - For multi-currency products, let Stripe handle currency conversion via
automatic_payment_methodsrather than building your own conversion logic. - Log every webhook event to your database (even ones you don't act on yet) — it becomes an invaluable audit trail when debugging billing disputes.
📌 Key Takeaways
- Stripe Checkout Sessions are the fastest, most secure way to accept one-time payments without building custom payment forms.
- Webhooks are not optional — they're the only reliable way to know what actually happened to a payment or subscription.
- Signature verification requires the raw request body; middleware ordering matters more than it seems.
- Subscriptions are built from reusable Products and Prices, not one-off charges, making them easier to manage and audit over time.
- The Stripe CLI and test mode let you build and validate your entire integration without touching real money.
Conclusion
Integrating Stripe into a Node.js application isn't just about calling an API to move money — it's about designing a system that stays correct even when networks fail, cards get declined, or webhooks arrive out of order. The good news is that Stripe has done the heavy lifting: PCI compliance, fraud prevention, and global payment methods are all handled for you.
Your job is to wire things together thoughtfully: use Checkout Sessions or Payment Intents for the initial charge, trust webhooks as your source of truth, and model subscriptions around Stripe's Product and Price objects instead of reinventing billing logic. Do that, and you'll have a payments system that's not just functional, but genuinely production-ready.
From here, a natural next step is exploring Stripe's Billing features in more depth — usage-based pricing, proration, and multi-seat plans — or adding Stripe Connect if you're building a marketplace that pays out multiple parties. But the foundation you've built in this guide — payments, webhooks, and subscriptions — is the backbone every one of those advanced features builds on top of.