March 26, 20268 min read

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 payments node.js api integration
Ad 336x280

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 thisComplexity
Accept one-time payments with minimal codeCheckout Sessions (hosted)Low
Build a custom payment formPayment Intents + ElementsMedium
Sell subscriptionsCheckout Sessions + WebhooksMedium
Marketplace payments (Uber, Etsy model)Stripe ConnectHigh
Invoice customersStripe InvoicingLow
No-code payment pagePayment LinksNone
For 90% of web apps, Checkout Sessions are the right answer. Stripe hosts the payment page, handles 3D Secure, supports Apple Pay and Google Pay out of the box, and you don't need to worry about PCI compliance. You redirect the customer to Stripe, they pay, Stripe redirects them back.

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:

  1. On first failure: email the customer asking them to update their payment method
  2. On subscription deletion: downgrade their account to free tier
  3. 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

RiskMitigation
Webhook spoofingAlways verify signatures with constructEvent()
Secret key exposureNever send sk_ key to frontend, use env vars
Price manipulationUse Stripe Price IDs, never accept amounts from frontend
CSRF on checkout creationRequire authenticated session
Replay attacksIdempotent webhook handlers

Common Mistakes

  1. Parsing the webhook body as JSON before verification. Signature verification needs the raw body. This is the number one integration bug.
  1. Trusting the success URL redirect. Anyone can visit /success?session_id=fake. Always verify server-side.
  1. 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.
  1. 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.
  1. Using test keys in production. Stripe test mode doesn't charge real cards. Double-check you're using live keys when you deploy.
The Stripe integration patterns covered here work for any backend — Express, Fastify, Next.js API routes, Go, Python. The concepts are the same, only the syntax changes. Practice building payment flows with project-based exercises on CodeUp.
Ad 728x90