SaaS Billing with Stripe — Checkout, Webhooks, Tier Sync
Professional Advanced 18 min read

SaaS Billing with Stripe — Checkout, Webhooks, Tier Sync

I wired Stripe into a market data service: checkout sessions, webhook handlers, and tier synchronization with Supabase. Here's the production setup.

By J. Martin · · web development
Table of Contents

Why Should You Care?

Billing is the part most developers avoid until they can’t. The Stripe docs are comprehensive but they don’t tell you how the pieces connect — checkout sessions, webhooks, subscription state, and your own database have to stay synchronized, and any gap between them means either charging users for nothing or giving access to users who’ve cancelled.

I built the billing integration for a market data service: $99/month, subscription gated, Supabase as the user store. This post covers the production setup — not the happy path, but the parts that actually matter: webhook idempotency, subscription state machines, and testing without hitting Stripe’s servers.


Architecture Overview

User clicks "Subscribe"


POST /api/checkout/create-session
  └─ Stripe: checkout.Session.create()
  └─ Returns: session.url


Stripe Checkout UI (Stripe-hosted)


Stripe sends webhook events
  └─ checkout.session.completed    → provision access
  └─ customer.subscription.updated → handle upgrades/downgrades
  └─ customer.subscription.deleted → revoke access


POST /api/webhooks/stripe
  └─ Verify signature
  └─ Update Supabase user metadata
  └─ Return 200


User redirected to /dashboard (success URL)
  └─ Reads Supabase session → tier = 'pro'
  └─ API gates check tier before serving data

The key insight: your app should never trust the checkout success redirect URL to grant access. Redirects can be replayed, modified, or missed. Only the webhook handler changes subscription state. The success redirect just tells the user “thanks, your account is being set up” while the webhook is already processing.


Step 1: Product and Price Setup

In the Stripe dashboard: create a Product (“Market Data Pro”), then a recurring Price ($99/month, USD). Copy the Price ID — you’ll hardcode it.

// src/config/stripe.ts

export const STRIPE_CONFIG = {
  priceId: 'price_1OxK2mLkjH8s9D2NqPmR4vBc', // $99/mo recurring
  successUrl: `${process.env.BASE_URL}/dashboard?billing=success`,
  cancelUrl: `${process.env.BASE_URL}/pricing`,
} as const;

Don’t put Price IDs in environment variables — they’re not secrets and you want them version-controlled. The secret key is the only thing that goes in .env.

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

Step 2: Checkout Session Creation

The checkout session is created server-side and returns a URL. Never create it client-side — the secret key must not be exposed.

// src/app/api/checkout/create-session/route.ts

import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
import { STRIPE_CONFIG } from '@/config/stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

export async function POST(req: NextRequest) {
  // Authenticate the user first
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  const authHeader = req.headers.get('Authorization');
  const token = authHeader?.replace('Bearer ', '');
  if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { data: { user }, error } = await supabase.auth.getUser(token);
  if (error || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Check if they already have a Stripe customer ID
  const { data: profile } = await supabase
    .from('profiles')
    .select('stripe_customer_id')
    .eq('id', user.id)
    .single();

  let customerId = profile?.stripe_customer_id;

  // Create Stripe customer if first time
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { supabase_user_id: user.id }, // critical for webhook lookup
    });
    customerId = customer.id;

    await supabase
      .from('profiles')
      .update({ stripe_customer_id: customerId })
      .eq('id', user.id);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    payment_method_types: ['card'],
    line_items: [{ price: STRIPE_CONFIG.priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: STRIPE_CONFIG.successUrl,
    cancel_url: STRIPE_CONFIG.cancelUrl,
    subscription_data: {
      metadata: { supabase_user_id: user.id }, // also on subscription for webhook
    },
  });

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

The metadata: { supabase_user_id: user.id } on both the customer and the subscription is essential. When the webhook fires, you’ll need to look up which user to update — that’s the bridge.


Step 3: Webhook Handler

The webhook handler is the source of truth. It receives events from Stripe, verifies they’re authentic, and updates Supabase accordingly.

// src/app/api/webhooks/stripe/route.ts

import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: NextRequest) {
  const body = await req.text(); // raw body required for signature verification
  const sig = req.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 NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Idempotency: check if we've already processed this event
  const { data: existing } = await supabase
    .from('stripe_events')
    .select('id')
    .eq('stripe_event_id', event.id)
    .single();

  if (existing) {
    // Already processed — return 200 so Stripe stops retrying
    return NextResponse.json({ received: true, duplicate: true });
  }

  // Mark event as processed before handling (prevent double-processing on retries)
  await supabase
    .from('stripe_events')
    .insert({ stripe_event_id: event.id, type: event.type, processed_at: new Date().toISOString() });

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
        break;
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
        break;
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;
      default:
        // Silently ignore events we don't handle
        break;
    }
  } catch (err) {
    console.error(`Error handling ${event.type}:`, err);
    // Return 500 — Stripe will retry with exponential backoff
    return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
  }

  return NextResponse.json({ received: true });
}

Always return 200 for events you’ve already processed. If you return 500, Stripe retries. If you’ve already committed the state change, a retry causes a duplicate write — at best harmless, at worst corrupting subscription state.

The idempotency pattern: insert the event ID before processing, check for it before starting. This handles the case where your handler crashes mid-processing — on retry, you’ll detect the event as already-seen and return 200 without re-running the handler. For truly critical operations (provisioning, billing credits), you’d want a more sophisticated pattern where you check the outcome rather than just whether the event was received.


Step 4: Tier Sync Handlers

// Event handlers — same file, below the router

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.supabase_user_id
    ?? session.subscription_data?.metadata?.supabase_user_id;

  if (!userId) {
    throw new Error(`No supabase_user_id in session ${session.id}`);
  }

  // Fetch the full subscription object — session only has the subscription ID
  const subscription = await stripe.subscriptions.retrieve(
    session.subscription as string
  );

  await syncSubscriptionToSupabase(userId, subscription);
}

async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.supabase_user_id;
  if (!userId) {
    // Fall back to customer metadata lookup
    const customer = await stripe.customers.retrieve(subscription.customer as string);
    if (customer.deleted) throw new Error('Customer deleted');
    const fallbackUserId = (customer as Stripe.Customer).metadata?.supabase_user_id;
    if (!fallbackUserId) throw new Error(`No user ID for subscription ${subscription.id}`);
    return syncSubscriptionToSupabase(fallbackUserId, subscription);
  }
  await syncSubscriptionToSupabase(userId, subscription);
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.supabase_user_id;
  if (!userId) throw new Error(`No user ID for deleted subscription ${subscription.id}`);

  await supabase
    .from('profiles')
    .update({
      tier: 'free',
      stripe_subscription_id: null,
      subscription_status: 'canceled',
      subscription_ends_at: null,
    })
    .eq('id', userId);
}

async function syncSubscriptionToSupabase(userId: string, subscription: Stripe.Subscription) {
  const tier = subscription.status === 'active' ? 'pro' : 'free';

  await supabase
    .from('profiles')
    .update({
      tier,
      stripe_subscription_id: subscription.id,
      subscription_status: subscription.status,
      // current_period_end is a Unix timestamp
      subscription_ends_at: new Date(subscription.current_period_end * 1000).toISOString(),
    })
    .eq('id', userId);
}

The Supabase profiles table schema for this:

alter table profiles add column if not exists tier text not null default 'free';
alter table profiles add column if not exists stripe_customer_id text;
alter table profiles add column if not exists stripe_subscription_id text;
alter table profiles add column if not exists subscription_status text;
alter table profiles add column if not exists subscription_ends_at timestamptz;

create table if not exists stripe_events (
  id uuid primary key default gen_random_uuid(),
  stripe_event_id text unique not null,
  type text not null,
  processed_at timestamptz not null,
  created_at timestamptz default now()
);

Step 5: API Tier Gating

With tier on the profile, gating is straightforward:

// src/lib/auth.ts

export async function requireProTier(req: NextRequest) {
  const user = await getAuthUser(req);
  if (!user) throw new AuthError('Unauthenticated', 401);

  const { data: profile } = await supabase
    .from('profiles')
    .select('tier, subscription_status, subscription_ends_at')
    .eq('id', user.id)
    .single();

  if (profile?.tier !== 'pro') {
    throw new AuthError('Subscription required', 403);
  }

  // Handle grace period: subscription canceled but still within paid period
  if (profile.subscription_status === 'canceled' && profile.subscription_ends_at) {
    const endsAt = new Date(profile.subscription_ends_at);
    if (endsAt > new Date()) {
      return user; // still in paid period, allow access
    }
  }

  if (profile.subscription_status !== 'active') {
    throw new AuthError('Subscription inactive', 403);
  }

  return user;
}
// src/app/api/market-data/signals/route.ts

export async function GET(req: NextRequest) {
  try {
    await requireProTier(req);
  } catch (err) {
    if (err instanceof AuthError) {
      return NextResponse.json({ error: err.message }, { status: err.status });
    }
    throw err;
  }

  const signals = await fetchMarketSignals();
  return NextResponse.json({ signals });
}

Testing with Stripe CLI

The Stripe CLI forwards webhook events from Stripe to your local server. Essential for development:

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

# Authenticate
stripe login

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

# Output:
# > Ready! Your webhook signing secret is whsec_test_... (copy to .env.local)
# 2026-03-30 14:23:01 --> customer.subscription.created [evt_1OxK...]
# 2026-03-30 14:23:01 <-- [200] POST http://localhost:3000/api/webhooks/stripe

Trigger specific events without going through the checkout UI:

# Simulate a successful checkout
stripe trigger checkout.session.completed

# Simulate subscription cancellation
stripe trigger customer.subscription.deleted

# Simulate failed payment (subscription goes to past_due)
stripe trigger invoice.payment_failed

For integration tests, use stripe-mock — a local Stripe API simulator that returns predictable responses. Install as a Docker container:

docker run --rm -p 12111:12111 stripe/stripe-mock:latest

Then point your Stripe client at it:

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
  ...(process.env.NODE_ENV === 'test' && {
    host: 'localhost',
    port: 12111,
    protocol: 'http',
  }),
});

Subscription Status States

Stripe’s subscription status field isn’t a simple active/inactive binary. You need to handle all of these:

StatusMeaningGate access?
activeCurrent, paidYes
trialingIn trial periodYes
past_duePayment failed, retryingGrace period (configurable)
canceledExplicitly canceledCheck current_period_end
unpaidRetries exhaustedNo
incompleteInitial payment failedNo
incomplete_expiredNever completedNo

The past_due handling is a judgment call. Stripe retries failed payments over several days (configurable in dashboard). During that time, the user’s subscription is technically unpaid but the payment might succeed. Most SaaS products keep access open during past_due and only revoke on unpaid or after a configured dunning period.

function shouldGrantAccess(status: string, endsAt: Date | null): boolean {
  if (status === 'active' || status === 'trialing') return true;
  if (status === 'past_due') return true; // generous: keep access during retry window
  if (status === 'canceled' && endsAt && endsAt > new Date()) return true; // paid through end of period
  return false;
}

What You Learned

  • Never trust the redirect URL to grant access. Only the webhook handler should change subscription state — redirects are for UX feedback, not authorization.
  • Idempotency is non-negotiable. Record the Stripe event ID before processing and return 200 for duplicates. Stripe retries on 4xx/5xx — without idempotency, retries cause double-processing.
  • The metadata bridge (supabase_user_id) is critical. Set it on both the Customer and the Subscription when creating them — you’ll need one or the other for every webhook lookup.
  • Subscription status is a state machine, not a boolean. Handle past_due, trialing, and the cancellation grace period explicitly — the happy path covers ~80% of users, the edge cases cover the other 20% who email support.
  • Test with stripe listen and stripe trigger. You can exercise every billing state locally without a real card or a real checkout, which makes billing logic testable like any other code.