Skip to content
Back to Monetization & Revenue

Stripe Integration

How to integrate Stripe Checkout + webhooks + customer portal for a SaaS, end to end.

Why

Stripe is the default payment processor for SaaS because it handles PCI compliance, 135+ currencies, and a mature webhook system out of the box. Building payments yourself means storing card numbers, which puts you in PCI-DSS Level 1 scope — an audit that costs $50k+ per year. Stripe Elements + Checkout shift the card input to Stripe-hosted iframes, so your servers never see a PAN and you stay PCI-SAQ-A. The webhook architecture is what makes Stripe robust: idempotent events, automatic retries with exponential backoff, and a signed payload that proves the event came from Stripe. Getting the integration right the first time prevents a class of bugs that are expensive to fix later — double-charges, missed entitlements, orphaned subscriptions.

When

When you launch a paid SaaS or add paid plans to a free product. Before writing any checkout code, confirm Stripe is available in your country and supports your business type (some restrict digital services).

How

1. Create a Stripe account, verify business details, and switch from test to live mode only after full E2E testing in test mode
2. Install the official SDK: npm install stripe, and initialize with the secret key from environment variables (never commit it)
3. Create a Product + Price for each plan via the Dashboard or API (stripe.products.create / stripe.prices.create)
4. Build the checkout session server-side: stripe.checkout.sessions.create with mode='subscription', line_items, success_url, cancel_url
5. Implement the webhook handler at /api/stripe-webhook: verify the signature with stripe.webhooks.constructEvent using the raw body and webhook secret
6. Handle these events at minimum: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed
7. Make every webhook handler idempotent: check if you already processed this event id before acting (store event ids in a table)
8. Embed the Customer Portal (stripe.billingPortal.sessions.create) so users can update card / cancel without your support
9. Write an E2E test using stripe.webhooks.generateTestHeaderString and fixture events before going live

Example

// server: create checkout session
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_abc123", quantity: 1 }],
  success_url: "https://app.example.com/billing?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: "https://app.example.com/pricing",
  customer_email: user.email,
  metadata: { userId: user.id },
});

// webhook handler (raw body required)
app.post("/api/stripe-webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const sig = req.headers["stripe-signature"]!;
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }
  if (await processedAlready(event.id)) return res.json({ received: true });
  switch (event.type) {
    case "checkout.session.completed":
      await grantEntitlement(event.data.object.metadata.userId);
      break;
    case "invoice.payment_failed":
      await dunningWorkflow(event.data.object.customer);
      break;
  }
  await markProcessed(event.id);
  res.json({ received: true });
});

Common Problems

  • ⚠Verifying the webhook signature with a parsed JSON body instead of the raw body — the signature is computed over the exact bytes, so body-parser destroys it. Use express.raw on the webhook route only.
  • ⚠Not making webhook handlers idempotent — Stripe retries on any non-2xx response, so a handler that grants entitlement on every call double-grants on retry. Store the event id and skip if seen.
  • ⚠Using the test secret key in production or vice versa — keys start with sk_test_ and sk_live_, but a config bug can ship the wrong one. Add a startup assertion that fails fast if the key prefix does not match NODE_ENV.
  • ⚠Forgetting to handle customer.subscription.deleted — the user cancels but your app keeps granting access because you only listen for checkout.session.completed. Entitlements must be derived from the current subscription status, not the checkout event.

Best Practices

  • ✓Store the stripe_customer_id on your user row on first checkout — you need it for the Customer Portal, refunds, and dunning, and creating a duplicate customer per charge breaks subscription management.
  • ✓Use Stripe's idempotency keys (the Idempotency-Key header) on all charge and refund requests so a network retry does not double-charge the customer.
  • ✓Subscribe to Stripe's webhook events in the Dashboard and select only the events you handle — receiving events you ignore wastes requests and increases the chance of a bug when you accidentally start handling one.
  • ✓Run the Stripe CLI local listener (stripe listen --forward-to localhost:3000/api/stripe-webhook) during development so you test webhooks without deploying.