Skip links

Stripe Integration Patterns for Subscription SaaS

Why Stripe Integration Is Harder Than It Looks

Every SaaS founder hits the same wall. You sign up for Stripe, drop in Checkout, and think billing is solved. Three months later you’re debugging webhook race conditions at 2 AM while a customer’s subscription is stuck in a zombie state — not active, not canceled, just broken.

Article Overview

Stripe Integration Patterns for Subscription SaaS

12 sections · Reading flow

01
Why Stripe Integration Is Harder Than It Looks
02
Choosing Your Billing Architecture
03
The Subscription Data Model
04
Webhook Architecture That Doesn't Break
05
The Checkout Flow
06
Handling Failed Payments and Dunning
07
Customer Portal Integration
08
Testing Stripe Locally
09
Reconciliation: Your Safety Net
10
Metering and Usage-Based Billing
11
Lessons From Production
12
What We'd Do Differently

HARBOR SOFTWARE · Engineering Insights

We’ve integrated Stripe into over a dozen subscription products at Harbor Software. Some were greenfield builds; others were rescues where a previous team’s Stripe integration had become the single biggest source of customer support tickets. The patterns that follow aren’t theoretical. They come from production systems processing tens of thousands of subscription events per month.

This post covers the architectural decisions, webhook handling strategies, and failure modes that separate a robust Stripe integration from one that quietly loses you money.

Choosing Your Billing Architecture

Before writing a line of code, you need to decide how much of the billing lifecycle Stripe owns versus your application. There are three common patterns, and each carries distinct trade-offs.

Pattern 1: Stripe-Led Billing

In this model, Stripe is the source of truth for subscription state. Your application reads from Stripe (via webhooks and API calls) but never independently tracks billing status. When you need to know if a user is on the Pro plan, you ask Stripe.

This works well for simple products with 2-3 plans and no usage-based components. The advantage is simplicity: you don’t need to reconcile two systems. The disadvantage is latency and coupling — every authorization check requires either a cached Stripe state or a live API call.

Pattern 2: Application-Led Billing

Here, your application maintains its own subscription state in your database. Stripe handles payment processing, but your app is the authority on what features a user can access. Webhooks sync Stripe events into your local state.

This is the pattern we recommend for most SaaS products. It gives you fast local lookups for authorization, the ability to implement grace periods and custom business logic, and resilience against Stripe API outages. The cost is complexity: you must handle event ordering, idempotency, and state reconciliation.

Pattern 3: Hybrid with Entitlements

Stripe’s newer Entitlements API attempts to bridge the gap. You define feature entitlements in Stripe’s dashboard, attach them to products, and query them at runtime. It’s promising but still maturing. We’ve used it for one project where the client wanted Stripe to be the single configuration point for plan features, but found edge cases around entitlement propagation delays that required local caching anyway.

For most teams reading this, Pattern 2 is the right choice. Let’s build it properly.

The Subscription Data Model

Your database needs to capture enough Stripe state to make authorization decisions without calling the API on every request. Here’s the schema we use as a starting point:

CREATE TABLE subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  stripe_customer_id TEXT NOT NULL,
  stripe_subscription_id TEXT UNIQUE,
  stripe_price_id TEXT,
  plan_tier TEXT NOT NULL DEFAULT 'free',
  status TEXT NOT NULL DEFAULT 'trialing',
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN DEFAULT FALSE,
  canceled_at TIMESTAMPTZ,
  trial_end TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_sub ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_stripe_cust ON subscriptions(stripe_customer_id);

A few things to note. We store stripe_price_id but also maintain our own plan_tier enum. This decouples your feature flags from Stripe’s product catalog. If you rename a plan in Stripe or create a new price for an existing tier, you update the mapping in one place rather than rewriting authorization logic across your codebase.

The status field mirrors Stripe’s subscription statuses: trialing, active, past_due, canceled, unpaid, incomplete, incomplete_expired, paused. Resist the temptation to simplify these into a boolean is_active. You will need the granularity. A user whose subscription is past_due should probably retain access for a grace period while you retry their payment. A user who is paused might need read-only access to export their data. These distinctions matter for retention.

Webhook Architecture That Doesn’t Break

Webhooks are where most Stripe integrations go wrong. Stripe delivers events via HTTP POST to your endpoint. Sounds simple. In practice, you’re dealing with out-of-order delivery, duplicate events, transient failures, and the need for idempotent processing.

The Webhook Endpoint

Here’s the pattern we use in every project. The endpoint itself does almost nothing — it validates the signature, persists the raw event, and returns 200 immediately. Actual processing happens asynchronously.

// app/api/webhooks/stripe/route.ts (Next.js App Router)
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { db } from '@/lib/db';
import { stripeWebhookQueue } from '@/lib/queue';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return new Response('Invalid signature', { status: 400 });
  }

  // Persist raw event for audit trail and replay
  await db.stripeEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      payload: JSON.stringify(event),
      processedAt: null,
    },
  });

  // Enqueue for async processing
  await stripeWebhookQueue.add(event.type, {
    eventId: event.id,
  });

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

Why persist before processing? Two reasons. First, if your processing logic throws an error, you haven’t lost the event. You can replay it. Second, Stripe has a 20-second timeout for webhook responses. If your processing takes longer (database writes, sending emails, provisioning resources), Stripe will retry, and you’ll process the event multiple times unless you handle idempotency.

Idempotent Event Processing

Every webhook handler must be idempotent. Stripe guarantees at-least-once delivery, which means you will receive duplicates. Here’s how we handle it:

// lib/webhook-processor.ts
export async function processStripeEvent(eventId: string) {
  const record = await db.stripeEvent.findUnique({
    where: { stripeEventId: eventId },
  });

  if (!record) return; // Event not found
  if (record.processedAt) return; // Already processed — idempotent guard

  const event = JSON.parse(record.payload) as Stripe.Event;

  try {
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionChange(event.data.object as Stripe.Subscription);
        break;
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;
      case 'invoice.payment_succeeded':
        await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
        break;
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object as Stripe.Invoice);
        break;
      default:
        // Log unhandled event types for monitoring
        console.log(`Unhandled event type: ${event.type}`);
    }

    await db.stripeEvent.update({
      where: { stripeEventId: eventId },
      data: { processedAt: new Date() },
    });
  } catch (err) {
    await db.stripeEvent.update({
      where: { stripeEventId: eventId },
      data: { error: String(err), failedAt: new Date() },
    });
    throw err; // Let the queue retry
  }
}

The processedAt check is your idempotency guard. If the event was already processed, you return early. No duplicate side effects.

Handling Out-of-Order Events

Stripe does not guarantee event ordering. You might receive customer.subscription.updated before customer.subscription.created. You might receive two updated events where the older one arrives last.

The safest approach is to treat every subscription event as a full state sync rather than a delta. When you receive any subscription event, read the subscription object from the event payload and overwrite your local state entirely:

async function handleSubscriptionChange(sub: Stripe.Subscription) {
  const priceId = sub.items.data[0]?.price.id;
  const planTier = mapPriceToTier(priceId);

  await db.subscription.upsert({
    where: { stripeSubscriptionId: sub.id },
    create: {
      userId: await getUserByStripeCustomer(sub.customer as string),
      stripeCustomerId: sub.customer as string,
      stripeSubscriptionId: sub.id,
      stripePriceId: priceId,
      planTier,
      status: sub.status,
      currentPeriodStart: new Date(sub.current_period_start * 1000),
      currentPeriodEnd: new Date(sub.current_period_end * 1000),
      cancelAtPeriodEnd: sub.cancel_at_period_end,
      canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
      trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
    },
    update: {
      stripePriceId: priceId,
      planTier,
      status: sub.status,
      currentPeriodStart: new Date(sub.current_period_start * 1000),
      currentPeriodEnd: new Date(sub.current_period_end * 1000),
      cancelAtPeriodEnd: sub.cancel_at_period_end,
      canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
      trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
    },
  });
}

The upsert handles both creation and updates, making event ordering irrelevant. Whether created or updated arrives first, the result is the same.

The Checkout Flow

Stripe Checkout is the fastest path to a working payment flow. You create a Checkout Session on the server, redirect the user to Stripe’s hosted page, and handle the result via webhooks and a return URL.

// app/api/checkout/route.ts
export async function POST(req: Request) {
  const { priceId } = await req.json();
  const user = await getCurrentUser();

  // Find or create Stripe customer
  let customerId = user.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { userId: user.id },
    });
    customerId = customer.id;
    await db.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    subscription_data: {
      trial_period_days: 14,
      metadata: { userId: user.id },
    },
    allow_promotion_codes: true,
  });

  return Response.json({ url: session.url });
}

Two critical details here. First, always pass the customer parameter. If you don’t, Stripe creates a new customer for every checkout, and you end up with orphaned customer records that are painful to reconcile. Second, put your userId in the subscription’s metadata. This lets your webhook handlers map Stripe events back to your users without an extra database lookup.

Handling Failed Payments and Dunning

Payment failures are inevitable. Cards expire, credit limits are reached, banks flag transactions. How you handle these failures directly impacts your revenue retention.

Stripe has built-in Smart Retries that automatically retry failed payments on an optimized schedule. You should enable this in your Stripe dashboard under Billing > Automatic collection. But you also need to respond to failures in your application.

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return;

  const sub = await db.subscription.findUnique({
    where: { stripeSubscriptionId: invoice.subscription as string },
    include: { user: true },
  });
  if (!sub) return;

  const attemptCount = invoice.attempt_count || 1;

  if (attemptCount === 1) {
    // First failure: gentle nudge
    await sendEmail(sub.user.email, 'payment-failed-soft', {
      updatePaymentUrl: `${process.env.APP_URL}/billing/update-payment`,
    });
  } else if (attemptCount === 3) {
    // Third failure: urgent warning
    await sendEmail(sub.user.email, 'payment-failed-urgent', {
      daysRemaining: 3,
      updatePaymentUrl: `${process.env.APP_URL}/billing/update-payment`,
    });
  }

  // Update local status — Stripe will have set it to past_due
  await db.subscription.update({
    where: { id: sub.id },
    data: { status: 'past_due' },
  });
}

We’ve found that sending a direct link to update payment details (using Stripe’s Customer Portal or a Billing Portal session) recovers 40-60% of failed payments. The email should be helpful, not threatening. “Your payment didn’t go through — here’s a link to update your card” converts far better than “Your account will be suspended.”

Customer Portal Integration

Stripe’s Customer Portal lets users manage their own subscriptions — update payment methods, switch plans, cancel, view invoices. It’s a massive time saver that eliminates an entire category of support requests.

// app/api/billing/portal/route.ts
export async function POST() {
  const user = await getCurrentUser();

  if (!user.stripeCustomerId) {
    return new Response('No billing account', { status: 400 });
  }

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

  return Response.json({ url: session.url });
}

Configure the portal in Stripe’s dashboard to allow the actions you want users to take. We typically enable plan switching and cancellation but disable pause — handling paused subscriptions adds complexity that most early-stage products don’t need.

Testing Stripe Locally

Stripe’s CLI is indispensable for local development. It forwards webhook events from Stripe’s test environment to your local server:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

This gives you a webhook signing secret for local use. You can also trigger specific events to test your handlers:

stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

For automated testing, we use Stripe’s test clocks. They let you simulate the passage of time so you can test renewal cycles, trial expirations, and dunning sequences without waiting days:

const testClock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000),
});

// Create customer attached to test clock
const customer = await stripe.customers.create({
  test_clock: testClock.id,
  email: 'test@example.com',
});

// Advance time to trigger trial end
await stripe.testHelpers.testClocks.advance(testClock.id, {
  frozen_time: Math.floor(Date.now() / 1000) + 15 * 24 * 60 * 60, // 15 days later
});

Reconciliation: Your Safety Net

No matter how bulletproof your webhook handling is, state can drift. Network issues, deployment gaps, bugs in event processing — all of these can cause your local subscription state to diverge from Stripe’s reality.

We run a nightly reconciliation job that compares every active local subscription against Stripe’s API:

async function reconcileSubscriptions() {
  const localSubs = await db.subscription.findMany({
    where: { status: { in: ['active', 'trialing', 'past_due'] } },
  });

  for (const local of localSubs) {
    if (!local.stripeSubscriptionId) continue;

    try {
      const stripeSub = await stripe.subscriptions.retrieve(
        local.stripeSubscriptionId
      );

      if (stripeSub.status !== local.status) {
        console.warn(
          `Status mismatch for ${local.stripeSubscriptionId}: ` +
          `local=${local.status}, stripe=${stripeSub.status}`
        );
        // Auto-fix or flag for manual review
        await handleSubscriptionChange(stripeSub);
      }
    } catch (err: any) {
      if (err.statusCode === 404) {
        console.warn(`Subscription ${local.stripeSubscriptionId} not found in Stripe`);
        // Mark as canceled locally
        await db.subscription.update({
          where: { id: local.id },
          data: { status: 'canceled' },
        });
      }
    }
  }
}

This has caught real issues in production. A missed webhook here, a processing error there — without reconciliation, those small drifts compound into billing disputes and lost revenue.

Metering and Usage-Based Billing

If your SaaS has usage-based components — API calls, storage, compute minutes — Stripe’s metered billing adds another layer. The key insight is that you report usage to Stripe; Stripe calculates the invoice amount.

We aggregate usage in our application database (for real-time display and rate limiting) and report to Stripe periodically for billing. Reporting happens via usage records on the subscription item:

await stripe.subscriptionItems.createUsageRecord(
  subscriptionItemId,
  {
    quantity: apiCallCount,
    timestamp: Math.floor(Date.now() / 1000),
    action: 'set', // 'set' replaces; 'increment' adds
  }
);

Use action: 'set' when you’re reporting cumulative usage for a period, and action: 'increment' when you’re reporting individual events. We prefer set with periodic batch reporting because it’s idempotent — if the report runs twice, the usage isn’t double-counted.

Lessons From Production

After building Stripe integrations across multiple SaaS products, here are the lessons that don’t fit neatly into code samples:

  • Always store Stripe IDs as TEXT, not VARCHAR with a length limit. Stripe IDs have gotten longer over time. A VARCHAR(50) that worked in 2020 might truncate newer IDs.
  • Log every webhook event, even ones you don’t handle. When debugging billing issues three months from now, you’ll want the full event history.
  • Use Stripe’s metadata fields aggressively. Put your user ID, team ID, and plan tier in subscription metadata. It makes debugging in the Stripe dashboard dramatically faster.
  • Handle the customer.subscription.trial_will_end event. Stripe sends this 3 days before a trial expires. It’s the perfect trigger for a conversion email — and most teams forget to implement it.
  • Don’t build your own proration logic. Stripe handles proration when switching plans. Trust it. The edge cases around mid-cycle upgrades and downgrades are not worth reimplementing.
  • Implement a billing admin panel early. Customer support will need to look up subscription states, view webhook event histories, and manually trigger reconciliation. Build this before you need it.

What We’d Do Differently

If we were starting a new Stripe integration today, we’d use Stripe’s newer Price model from day one instead of the legacy Plans API. We’d set up the Customer Portal immediately rather than building custom billing management UI. And we’d implement webhook event persistence and async processing from the start, rather than adding it after the first production incident.

Stripe’s API surface is enormous, and it’s tempting to use every feature. Resist that temptation. Start with Checkout, webhooks, and the Customer Portal. Add complexity only when your business requires it. The patterns in this post will serve you well from launch through scaling — they’re the same patterns running in our production systems today.

Leave a comment

Explore
Drag