The Complete Guide to Stripe Integration

Master Stripe payments in your SaaS application. From checkout sessions to webhooks, subscriptions to customer portals.

SaaS Team
4 min read

Integrating payments is one of the most critical parts of building a SaaS application. Stripe makes this process straightforward, but there are important patterns and pitfalls to understand.

Setting Up Stripe

First, install the Stripe SDK:

pnpm add stripe @stripe/stripe-js

Create a server-side Stripe client:

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-11-20.acacia",
  typescript: true,
});

Creating Products and Prices

Before accepting payments, set up your products in Stripe Dashboard or via API:

// Create a product
const product = await stripe.products.create({
  name: "Pro Plan",
  description: "Full access to all features",
});

// Create a recurring price
const price = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900, // $29.00
  currency: "usd",
  recurring: {
    interval: "month",
  },
});

Checkout Sessions

The simplest way to accept payments is through Checkout Sessions:

export async function createCheckoutSession(
  userId: string,
  priceId: string
) {
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
    metadata: {
      userId,
    },
  });

  return session;
}

Webhook Handling

Webhooks are essential for keeping your database in sync with Stripe:

import { headers } from "next/headers";
import { stripe } from "@/lib/billing/stripe";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response("Webhook signature verification failed", {
      status: 400,
    });
  }

  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutComplete(event.data.object);
      break;
    case "customer.subscription.updated":
      await handleSubscriptionUpdate(event.data.object);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object);
      break;
  }

  return new Response("OK", { status: 200 });
}

Managing Subscriptions

Checking Subscription Status

Always check subscription status server-side:

export async function getUserSubscription(userId: string) {
  const subscription = await prisma.subscription.findUnique({
    where: { userId },
  });

  if (!subscription) {
    return { plan: "free", status: "none" };
  }

  return {
    plan: subscription.plan,
    status: subscription.status,
    currentPeriodEnd: subscription.currentPeriodEnd,
  };
}

Upgrading and Downgrading

Handle plan changes gracefully:

export async function updateSubscription(
  subscriptionId: string,
  newPriceId: string
) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await stripe.subscriptions.update(subscriptionId, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: "create_prorations",
  });
}

Cancellation

Let users cancel while retaining access until period end:

export async function cancelSubscription(subscriptionId: string) {
  await stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: true,
  });
}

Customer Portal

Stripe's Customer Portal handles billing management:

export async function createPortalSession(customerId: string) {
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
  });

  return session.url;
}

Testing Payments

Use Stripe's test mode and test card numbers:

Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Declined card
4000 0000 0000 32203D Secure required

Handling Failed Payments

Set up dunning management in Stripe Dashboard, and handle failed payment webhooks:

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const userId = invoice.metadata?.userId;

  if (userId) {
    // Send email notification
    await sendEmail({
      to: invoice.customer_email!,
      subject: "Payment failed",
      template: "payment-failed",
    });

    // Update user status in database
    await prisma.user.update({
      where: { id: userId },
      data: { paymentStatus: "failed" },
    });
  }
}

Security Best Practices

  1. Never expose secret keys - Keep STRIPE_SECRET_KEY server-side only
  2. Verify webhook signatures - Always validate incoming webhooks
  3. Use idempotency keys - Prevent duplicate charges on retries
  4. Log everything - Keep audit trails of all payment events

Common Pitfalls

1. Not Handling Webhook Retries

Stripe retries failed webhooks. Make your handlers idempotent:

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  // Check if already processed
  const existing = await prisma.subscription.findFirst({
    where: { stripeSessionId: session.id },
  });

  if (existing) {
    return; // Already processed
  }

  // Process the checkout...
}

2. Ignoring Subscription States

Subscriptions have multiple states (active, past_due, canceled, etc.). Handle them appropriately:

function hasActiveSubscription(status: string) {
  return ["active", "trialing"].includes(status);
}

3. Not Testing Edge Cases

Test these scenarios:

  • Payment failures during checkout
  • Subscription upgrades and downgrades
  • Cancellations and reactivations
  • Currency changes
  • Tax calculations

Conclusion

Stripe integration doesn't have to be complex. Start with Checkout Sessions, add webhook handling for state synchronization, and use the Customer Portal for billing management. This combination covers 90% of SaaS payment needs.

Newsletter

Stay in the loop

Get the latest articles, tutorials, and product updates delivered straight to your inbox. No spam, unsubscribe anytime.

Join 1,000+ developers already subscribed

Related Articles