---
id: stripe-billing-wire
name: stripe-billing-wire
description: Wire Stripe subscription billing into a SaaS project — customer creation, checkout flow, webhook handler, customer portal link, and the subscriptions table.
version: 1.0.0
source: bundled
tags: [billing, stripe, saas, nextjs]
---

# Skill: stripe-billing-wire

Use this skill when the user wants to **add Stripe subscription billing**
to a SaaS project. Assumes Clerk auth is already wired (use
`clerk-wire` first if not).

## Pre-flight checks

1. Confirm `stripe` is in `package.json` deps; install if missing
2. Verify Clerk is wired (look for `middleware.ts` with `clerkMiddleware`)
3. Confirm `users` table exists with `clerk_id` + `stripe_customer_id` columns
4. Check `.env.local` for `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`
5. Ask the user for plan tier names + monthly prices if not in `lib/stripe.ts`

## Files to create / modify

### `lib/stripe.ts`

```ts
import Stripe from 'stripe';
import { getServiceSupabase } from './db';

let _stripe: Stripe | null = null;

export function getStripe(): Stripe {
  if (_stripe) return _stripe;
  const key = process.env.STRIPE_SECRET_KEY;
  if (!key) throw new Error('STRIPE_SECRET_KEY must be set');
  // Stripe SDK 17+ requires apiVersion '2025-02-24.acacia'.
  // Older releases pin '2024-12-18.acacia'. Update when you bump the SDK.
  _stripe = new Stripe(key, { apiVersion: '2025-02-24.acacia' });
  return _stripe;
}

export interface Plan {
  id: string;
  name: string;
  priceMonthly: number;
  stripePriceId?: string;
  features: string[];
}

export function listPlans(): Plan[] {
  return [
    { id: 'starter', name: 'Starter', priceMonthly: 0, features: ['1 project'] },
    { id: 'pro', name: 'Pro', priceMonthly: 29, stripePriceId: process.env.STRIPE_PRICE_PRO, features: ['Unlimited', 'Priority support'] },
    { id: 'team', name: 'Team', priceMonthly: 99, stripePriceId: process.env.STRIPE_PRICE_TEAM, features: ['Everything in Pro', '10 seats', 'SSO'] },
  ];
}

export async function getOrCreateStripeCustomerId(clerkUserId: string): Promise<string> {
  const supabase = getServiceSupabase();
  const { data: existing } = await supabase
    .from('users')
    .select('stripe_customer_id, email')
    .eq('clerk_id', clerkUserId)
    .maybeSingle();
  if (existing?.stripe_customer_id) return existing.stripe_customer_id;

  const customer = await getStripe().customers.create({
    email: existing?.email || undefined,
    metadata: { clerk_user_id: clerkUserId },
  });
  await supabase.from('users').update({ stripe_customer_id: customer.id }).eq('clerk_id', clerkUserId);
  return customer.id;
}
```

### `app/api/billing/checkout/route.ts`

```ts
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { getStripe, getOrCreateStripeCustomerId, listPlans } from '@/lib/stripe';

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const url = new URL(req.url);
  const planId = url.searchParams.get('plan');
  const plan = listPlans().find(p => p.id === planId);
  if (!plan?.stripePriceId) return NextResponse.json({ error: 'invalid_plan' }, { status: 400 });

  const customerId = await getOrCreateStripeCustomerId(userId);
  const session = await getStripe().checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: plan.stripePriceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
  });
  return NextResponse.redirect(session.url!);
}
```

### `app/api/billing/portal/route.ts` — customer portal

```ts
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { getStripe, getOrCreateStripeCustomerId } from '@/lib/stripe';

export async function POST() {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const customerId = await getOrCreateStripeCustomerId(userId);
  const session = await getStripe().billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
  });
  return NextResponse.redirect(session.url);
}
```

### `app/api/webhooks/stripe/route.ts` — lifecycle sync

See the bundled `nextjs-supabase-clerk-stripe` template for the complete
signature-verified handler. Subscribes to:

- `customer.subscription.created` / `updated` → upsert `subscriptions` row
- `customer.subscription.deleted` → mark `status='canceled'`
- `checkout.session.completed` → optional `welcome` event

### Database table — `subscriptions`

```sql
create table if not exists public.subscriptions (
  stripe_subscription_id  text primary key,
  stripe_customer_id      text not null references public.users(stripe_customer_id) on delete cascade,
  status                  text not null,
  price_id                text,
  current_period_end      timestamptz,
  canceled_at             timestamptz,
  created_at              timestamptz not null default now()
);
alter table public.subscriptions enable row level security;
create policy "self-select subscriptions" on public.subscriptions
  for select using (
    stripe_customer_id in (
      select stripe_customer_id from public.users where clerk_id = auth.jwt() ->> 'sub'
    )
  );
```

## Required environment

```bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_TEAM=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

## Post-install steps

1. Create Products + Prices in https://dashboard.stripe.com → Products
   - Create one Price per plan (Pro, Team)
   - Copy each Price id into `STRIPE_PRICE_PRO`, `STRIPE_PRICE_TEAM`
2. Local webhook dev: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`
   (copy the printed `whsec_...` into `STRIPE_WEBHOOK_SECRET`)
3. Production webhook: https://dashboard.stripe.com → Developers → Webhooks → Add Endpoint
   - URL: `https://<your-domain>/api/webhooks/stripe`
   - Events: `customer.subscription.*`, `checkout.session.completed`
4. Verify with a test subscription using Stripe's test card `4242 4242 4242 4242`

## Composition

- Requires `clerk-wire` first (uses `auth()` server helper)
- Requires `supabase-rls-wire` for the `subscriptions` table + RLS
- Pairs with `vercel-deploy-wire` for env-var setup in production
