Stripe Payment Integration: From Zero to Production in 2025.

Complete guide to integrating Stripe payments in your SaaS. Subscriptions, webhooks, customer portal, and billing management made simple.

9 min readFederico Fan
StripePaymentsSaaSSubscriptionsIntegration

Stripe Payment Integration: From Zero to Production in 2025

Stripe is the backbone of successful SaaS businesses. Get payments right, and you can focus on building features. Get them wrong, and you'll lose customers and sleep.

Here's the complete blueprint for Stripe integration that I use for every SaaS project.


Why Stripe Dominates SaaS Payments

  • Global reach → Accept payments worldwide
  • Subscription management → Built-in recurring billing
  • Developer experience → Best-in-class APIs
  • Compliance → PCI DSS handled for you
  • Webhooks → Real-time event notifications
  • Customer portal → Self-service billing

Bottom line: Stripe handles the complexity, you handle the growth.


Project Setup

1. Install Dependencies

npm install stripe @stripe/stripe-js
npm install -D @types/stripe

2. Environment Variables

# .env.local
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000

3. Stripe Configuration

// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2023-10-16",
  typescript: true,
});

// Client-side configuration
// lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";

export const getStripe = () => {
  return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
};

Database Schema for Payments

model User {
  id                     String   @id @default(cuid())
  email                  String   @unique
  name                   String?

  // Stripe customer data
  stripeCustomerId       String?  @unique

  // Subscription info
  stripeSubscriptionId   String?  @unique
  stripePriceId          String?
  stripeCurrentPeriodEnd DateTime?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Product {
  id          String  @id @default(cuid())
  name        String
  description String?

  // Stripe product ID
  stripeId    String  @unique

  // Pricing tiers
  prices      Price[]

  active      Boolean @default(true)
  metadata    Json?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("products")
}

model Price {
  id              String        @id @default(cuid())
  productId       String

  // Stripe price ID
  stripeId        String        @unique

  // Pricing details
  unitAmount      Int           // Amount in cents
  currency        String        @default("usd")
  interval        PriceInterval
  intervalCount   Int           @default(1)

  // Display
  nickname        String?

  active          Boolean       @default(true)

  product Product @relation(fields: [productId], references: [id], onDelete: Cascade)

  @@map("prices")
}

enum PriceInterval {
  MONTH
  YEAR
}

Subscription Checkout Flow

1. Create Checkout Session

// app/api/stripe/create-checkout/route.ts
import { stripe } from "@/lib/stripe";
import { getCurrentUser } from "@/lib/auth";

export async function POST(request: Request) {
  try {
    const { priceId } = await request.json();
    const user = await getCurrentUser();

    if (!user) {
      return new Response("Unauthorized", { status: 401 });
    }

    // Create or retrieve Stripe customer
    let customerId = user.stripeCustomerId;

    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        name: user.name || undefined,
        metadata: {
          userId: user.id,
        },
      });

      customerId = customer.id;

      // Update user with customer ID
      await prisma.user.update({
        where: { id: user.id },
        data: { stripeCustomerId: customerId },
      });
    }

    // Create checkout session
    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      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`,
      allow_promotion_codes: true,
      billing_address_collection: "required",
      customer_update: {
        address: "auto",
        name: "auto",
      },
    });

    return Response.json({ sessionId: session.id });
  } catch (error) {
    console.error("Error creating checkout session:", error);
    return new Response("Internal Server Error", { status: 500 });
  }
}

2. Client-Side Checkout

// components/checkout-button.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { getStripe } from "@/lib/stripe-client";

interface CheckoutButtonProps {
  priceId: string;
  children: React.ReactNode;
}

export function CheckoutButton({ priceId, children }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);

    try {
      // Create checkout session
      const response = await fetch("/api/stripe/create-checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId }),
      });

      const { sessionId } = await response.json();

      // Redirect to Stripe Checkout
      const stripe = await getStripe();
      const { error } = await stripe!.redirectToCheckout({ sessionId });

      if (error) {
        console.error("Stripe error:", error);
      }
    } catch (error) {
      console.error("Checkout error:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button onClick={handleCheckout} disabled={loading} className="w-full">
      {loading ? "Loading..." : children}
    </Button>
  );
}

Webhook Integration

1. Webhook Handler

// app/api/stripe/webhooks/route.ts
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return new Response("Webhook signature verification failed", {
      status: 400,
    });
  }

  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;
        await handleCheckoutCompleted(session);
        break;
      }

      case "customer.subscription.updated": {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionUpdated(subscription);
        break;
      }

      case "customer.subscription.deleted": {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionDeleted(subscription);
        break;
      }

      case "invoice.payment_succeeded": {
        const invoice = event.data.object as Stripe.Invoice;
        await handlePaymentSucceeded(invoice);
        break;
      }

      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice;
        await handlePaymentFailed(invoice);
        break;
      }

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return new Response("Webhook handled successfully", { status: 200 });
  } catch (error) {
    console.error("Webhook handler error:", error);
    return new Response("Webhook handler failed", { status: 500 });
  }
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  if (session.mode === "subscription") {
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    );

    await updateUserSubscription(session.customer as string, subscription);
  }
}

async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  await updateUserSubscription(subscription.customer as string, subscription);
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  await prisma.user.updateMany({
    where: { stripeCustomerId: subscription.customer as string },
    data: {
      stripeSubscriptionId: null,
      stripePriceId: null,
      stripeCurrentPeriodEnd: null,
    },
  });
}

async function updateUserSubscription(
  customerId: string,
  subscription: Stripe.Subscription
) {
  const price = subscription.items.data[0]?.price;

  await prisma.user.updateMany({
    where: { stripeCustomerId: customerId },
    data: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: price?.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

2. Webhook Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhooks

Customer Portal Integration

1. Create Portal Session

// app/api/stripe/create-portal/route.ts
export async function POST(request: Request) {
  try {
    const user = await getCurrentUser();

    if (!user?.stripeCustomerId) {
      return new Response("No customer found", { status: 404 });
    }

    const session = await stripe.billingPortal.sessions.create({
      customer: user.stripeCustomerId,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
    });

    return Response.json({ url: session.url });
  } catch (error) {
    console.error("Error creating portal session:", error);
    return new Response("Internal Server Error", { status: 500 });
  }
}

2. Portal Button Component

// components/manage-subscription-button.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";

export function ManageSubscriptionButton() {
  const [loading, setLoading] = useState(false);

  const handleManageSubscription = async () => {
    setLoading(true);

    try {
      const response = await fetch("/api/stripe/create-portal", {
        method: "POST",
      });

      const { url } = await response.json();
      window.location.href = url;
    } catch (error) {
      console.error("Error redirecting to portal:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button
      onClick={handleManageSubscription}
      disabled={loading}
      variant="outline"
    >
      {loading ? "Loading..." : "Manage Subscription"}
    </Button>
  );
}

Usage-Based Billing

1. Report Usage

// Report usage for metered billing
export async function reportUsage(
  subscriptionItemId: string,
  quantity: number,
  timestamp?: number
) {
  try {
    await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
      quantity,
      timestamp: timestamp || Math.floor(Date.now() / 1000),
      action: "increment",
    });
  } catch (error) {
    console.error("Error reporting usage:", error);
  }
}

// Usage tracking middleware
export async function trackAPIUsage(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { stripeSubscriptionId: true },
  });

  if (user?.stripeSubscriptionId) {
    const subscription = await stripe.subscriptions.retrieve(
      user.stripeSubscriptionId
    );

    // Find metered item
    const meteredItem = subscription.items.data.find(
      (item) => item.price.billing_scheme === "per_unit"
    );

    if (meteredItem) {
      await reportUsage(meteredItem.id, 1);
    }
  }
}

Subscription Status Utilities

// lib/subscription.ts
export async function getUserSubscription(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      stripeSubscriptionId: true,
      stripePriceId: true,
      stripeCurrentPeriodEnd: true,
      stripeCustomerId: true,
    },
  });

  if (!user) return null;

  const isActive =
    user.stripeSubscriptionId &&
    user.stripeCurrentPeriodEnd &&
    user.stripeCurrentPeriodEnd > new Date();

  return {
    ...user,
    isActive,
    isPro: isActive && user.stripePriceId === "price_pro_monthly",
  };
}

export async function requireSubscription(userId: string) {
  const subscription = await getUserSubscription(userId);

  if (!subscription?.isActive) {
    throw new Error("Active subscription required");
  }

  return subscription;
}

// Usage in API routes
export async function GET(request: Request) {
  const user = await getCurrentUser();
  const subscription = await requireSubscription(user.id);

  // Protected functionality here
  return Response.json({ data: "pro-only-data" });
}

Error Handling & Monitoring

1. Payment Error Handling

// components/checkout-button.tsx
const handleCheckout = async () => {
  setLoading(true);
  setError(null);

  try {
    const response = await fetch("/api/stripe/create-checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(errorData.error || "Checkout failed");
    }

    const { sessionId } = await response.json();
    const stripe = await getStripe();

    const { error } = await stripe!.redirectToCheckout({ sessionId });

    if (error) {
      setError(error.message);
    }
  } catch (error) {
    setError(error instanceof Error ? error.message : "Something went wrong");
  } finally {
    setLoading(false);
  }
};

2. Webhook Monitoring

// lib/monitoring.ts
export async function logWebhookEvent(
  eventType: string,
  success: boolean,
  error?: string
) {
  await prisma.webhookLog.create({
    data: {
      eventType,
      success,
      error,
      timestamp: new Date(),
    },
  });
}

// Add to webhook handler
try {
  // Process webhook...
  await logWebhookEvent(event.type, true);
} catch (error) {
  await logWebhookEvent(event.type, false, error.message);
  throw error;
}

Testing Strategy

1. Test Cards

// Use Stripe test cards
const testCards = {
  visa: "4242424242424242",
  visaDebit: "4000056655665556",
  declined: "4000000000000002",
  requiresAuthentication: "4000002760003184",
};

2. Integration Tests

// __tests__/stripe-integration.test.ts
describe("Stripe Integration", () => {
  test("creates checkout session", async () => {
    const response = await fetch("/api/stripe/create-checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId: "price_test_123" }),
    });

    const data = await response.json();
    expect(data.sessionId).toBeTruthy();
  });

  test("handles webhook events", async () => {
    const mockEvent = createMockWebhookEvent("checkout.session.completed");

    const response = await fetch("/api/stripe/webhooks", {
      method: "POST",
      headers: { "stripe-signature": "test_signature" },
      body: JSON.stringify(mockEvent),
    });

    expect(response.status).toBe(200);
  });
});

Security Best Practices

  1. Always verify webhooks with signature verification
  2. Use HTTPS in production for all Stripe interactions
  3. Store minimal data - let Stripe be the source of truth
  4. Implement idempotency for webhook handlers
  5. Rate limit checkout endpoints
  6. Log all payment events for debugging

This Stripe integration approach has processed millions in revenue for SaaS products I've built.

Need this implemented in your product? €900/month gets you production-ready payment flows.

Get started →


P.S. Payments are the heart of your SaaS. Get them right, and everything else becomes easier.

MORE ARTICLES

Continue reading