Stripe Payment Integration — Complete Developer Guide
Integrate Stripe payments into your web app: Checkout Sessions, Payment Intents, webhooks, subscriptions, and handling edge cases that tutorials skip.
Stripe's documentation is excellent but it's also 10,000 pages across dozens of products. When you just want to accept payments in your web app, you don't know where to start — Checkout? Payment Intents? Elements? Payment Links?
Here's the decision framework, then the implementation for the two most common patterns: one-time payments and subscriptions.
Which Stripe Integration Do You Need?
| You want to... | Use this | Complexity |
|---|---|---|
| Accept one-time payments with minimal code | Checkout Sessions (hosted) | Low |
| Build a custom payment form | Payment Intents + Elements | Medium |
| Sell subscriptions | Checkout Sessions + Webhooks | Medium |
| Marketplace payments (Uber, Etsy model) | Stripe Connect | High |
| Invoice customers | Stripe Invoicing | Low |
| No-code payment page | Payment Links | None |
Setup
npm install stripe
// lib/stripe.js
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-12-18.acacia",
});
module.exports = stripe;
You need two keys from the Stripe dashboard: a publishable key (starts with pk_) for the frontend and a secret key (starts with sk_) for the backend. Use test keys (starting with pk_test_ and sk_test_) during development.
Pattern 1: One-Time Payment with Checkout
The flow: your backend creates a Checkout Session, the frontend redirects to it, the customer pays, Stripe redirects back.
Backend: Create Checkout Session
// routes/checkout.js
const express = require("express");
const stripe = require("../lib/stripe");
const router = express.Router();
router.post("/create-checkout-session", async (req, res) => {
const { priceId, quantity = 1 } = req.body;
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price: priceId, // Price ID from Stripe Dashboard
quantity,
},
],
success_url: ${process.env.CLIENT_URL}/success?session_id={CHECKOUT_SESSION_ID},
cancel_url: ${process.env.CLIENT_URL}/cancel,
metadata: {
userId: req.user?.id, // Attach your own data
},
});
res.json({ url: session.url });
} catch (err) {
console.error("Checkout session error:", err);
res.status(500).json({ error: "Failed to create checkout session" });
}
});
Frontend: Redirect to Checkout
async function handleCheckout() {
const response = await fetch("/api/create-checkout-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId: "price_xxx" }),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe-hosted page
}
That's it for the basic flow. The customer sees a Stripe-hosted payment page, enters their card, and gets redirected to your success URL.
Verifying Payment on Success Page
Don't trust the redirect alone. Anyone can visit your /success URL. Verify the session:
// routes/checkout.js
router.get("/verify-session", async (req, res) => {
const { session_id } = req.query;
try {
const session = await stripe.checkout.sessions.retrieve(session_id);
if (session.payment_status === "paid") {
res.json({
success: true,
customer_email: session.customer_details?.email,
amount: session.amount_total,
});
} else {
res.json({ success: false });
}
} catch (err) {
res.status(400).json({ success: false });
}
});
Pattern 2: Subscriptions
Subscriptions add complexity because of recurring billing, plan changes, cancellations, and failed payments. The webhook handler does the heavy lifting.
Creating a Subscription Checkout
router.post("/create-subscription", async (req, res) => {
const { priceId, email } = req.body;
try {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
success_url: ${process.env.CLIENT_URL}/dashboard?session_id={CHECKOUT_SESSION_ID},
cancel_url: ${process.env.CLIENT_URL}/pricing,
subscription_data: {
trial_period_days: 14, // Optional free trial
metadata: {
userId: req.user?.id,
},
},
});
res.json({ url: session.url });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Managing Subscriptions: The Customer Portal
Stripe's Customer Portal handles plan changes, cancellations, and payment method updates. You don't have to build any of that:
router.post("/create-portal-session", async (req, res) => {
const { customerId } = req.body;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: ${process.env.CLIENT_URL}/settings/billing,
});
res.json({ url: session.url });
});
Configure the portal in the Stripe Dashboard (Settings → Customer Portal) to control what customers can do: change plans, cancel, update payment method, view invoices.
Webhooks: The Part Everyone Gets Wrong
Webhooks are how Stripe tells your server about events: successful payments, failed charges, subscription changes, disputes. If you only rely on the checkout redirect, you'll miss events. The webhook is the source of truth.
Setting Up the Webhook Endpoint
// routes/webhooks.js
const express = require("express");
const stripe = require("../lib/stripe");
const router = express.Router();
// IMPORTANT: Use raw body for webhook signature verification
router.post(
"/stripe",
express.raw({ type: "application/json" }),
async (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 failed:", err.message);
return res.status(400).send(Webhook Error: ${err.message});
}
// Handle the event
try {
await handleStripeEvent(event);
res.json({ received: true });
} catch (err) {
console.error("Webhook handler error:", err);
res.status(500).json({ error: "Webhook handler failed" });
}
}
);
Critical detail: the webhook route needs the raw request body for signature verification. If you're using express.json() globally, the body gets parsed before you can verify it. Register the webhook route before any body-parsing middleware, or use a separate router.
Handling Webhook Events
async function handleStripeEvent(event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode === "subscription") {
await activateSubscription(session);
} else {
await fulfillOrder(session);
}
break;
}
case "invoice.payment_succeeded": {
// Recurring payment succeeded — extend subscription
const invoice = event.data.object;
await extendSubscription(invoice);
break;
}
case "invoice.payment_failed": {
// Payment failed — notify user, maybe downgrade
const invoice = event.data.object;
await handleFailedPayment(invoice);
break;
}
case "customer.subscription.updated": {
// Plan change, cancellation scheduled, etc.
const subscription = event.data.object;
await syncSubscriptionStatus(subscription);
break;
}
case "customer.subscription.deleted": {
// Subscription actually ended
const subscription = event.data.object;
await deactivateSubscription(subscription);
break;
}
default:
console.log(Unhandled event type: ${event.type});
}
}
Testing Webhooks Locally
Use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This gives you a webhook signing secret (whsec_...) for local testing. It forwards all Stripe test events to your local server.
Trigger test events:
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
Handling Edge Cases
These are the things that break in production:
Idempotency
Stripe may deliver the same webhook event multiple times. Your handler must be idempotent — processing the same event twice shouldn't create duplicate records:
async function fulfillOrder(session) {
// Check if already processed
const existing = await db.order.findUnique({
where: { stripeSessionId: session.id },
});
if (existing) return; // Already handled
await db.order.create({
data: {
stripeSessionId: session.id,
userId: session.metadata.userId,
amount: session.amount_total,
status: "completed",
},
});
}
Currency Handling
Stripe amounts are in the smallest currency unit. For USD, that's cents. $19.99 is 1999. Don't mess this up:
// WRONG: This charges $1,999.00
await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: "usd",
unit_amount: 1999, // This is $19.99, not $1,999
product_data: { name: "Pro Plan" },
},
quantity: 1,
}],
});
// Helper to convert dollars to cents
const toCents = (dollars) => Math.round(dollars * 100);
Failed Payments and Dunning
When a subscription payment fails, Stripe retries automatically (configurable in Dashboard → Settings → Subscriptions). After all retries fail, the subscription is canceled. Your webhook handler should:
- On first failure: email the customer asking them to update their payment method
- On subscription deletion: downgrade their account to free tier
- Never lock users out immediately — give them a grace period
async function handleFailedPayment(invoice) {
const customerId = invoice.customer;
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
});
if (user) {
await sendEmail({
to: user.email,
template: "payment-failed",
data: {
amount: (invoice.amount_due / 100).toFixed(2),
updateUrl: ${process.env.CLIENT_URL}/settings/billing,
},
});
}
}
Security Checklist
| Risk | Mitigation |
|---|---|
| Webhook spoofing | Always verify signatures with constructEvent() |
| Secret key exposure | Never send sk_ key to frontend, use env vars |
| Price manipulation | Use Stripe Price IDs, never accept amounts from frontend |
| CSRF on checkout creation | Require authenticated session |
| Replay attacks | Idempotent webhook handlers |
Common Mistakes
- Parsing the webhook body as JSON before verification. Signature verification needs the raw body. This is the number one integration bug.
- Trusting the success URL redirect. Anyone can visit
/success?session_id=fake. Always verify server-side.
- Hardcoding prices instead of using Price IDs. Create products and prices in the Stripe Dashboard or via the API. Reference them by ID. This lets you change prices without redeploying.
- Not handling subscription lifecycle events. You need to handle creation, renewal, failure, cancellation, and plan changes. Missing any of these means your database gets out of sync with Stripe.
- Using test keys in production. Stripe test mode doesn't charge real cards. Double-check you're using live keys when you deploy.