PAYMENTSTEMPLATE

Stripe Subscription Billing

Here is how most subscription integrations die. Someone wires up the Checkout button, sees the success page, and updates the database right there on the redirect. It works in the demo. Then month two arrives, a card gets declined, Stripe cancels the subscription, and the app never hears about it because nobody clicked anything. The customer keeps full access, forever, free. That is not a hypothetical: it is the default outcome of trusting the browser to tell you about money.

The fix is a rule, and this spec enforces it: the webhook handler is the only code allowed to write billing state. Checkout starts the process, the customer portal lets people cancel and update cards without you building any of that UI, and the webhook is where the database learns what actually happened. Everything else just reads.

The other thing this spec bakes in: Stripe's customer portal instead of hand-rolled cancel and upgrade screens. People burn weeks building subscription management UI that Stripe already hosts, keeps compliant, and updates when card networks change the rules. Your job is one redirect. Take the free win.

Hand this to your agent with the placeholders filled in. It assumes auth already exists; if it doesn't, build that first, because a subscription without a user to attach it to is just a donation.

Prerequisites

  • A Stripe account with a product and recurring price created in the dashboard (test mode is fine).
  • The Stripe CLI installed for local webhook forwarding (`stripe listen`).
  • Working auth in your app: you can identify the logged-in user server-side.
markdown
# Spec: Stripe subscription billing

Build recurring billing for [product name] using Stripe Checkout, the Stripe
customer portal, and a single webhook handler that is the only writer of
billing state in the database.

## Stack

- Framework: [e.g., Next.js 14 App Router / Express / FastAPI]
- Database: [e.g., Supabase Postgres / Postgres / SQLite]
- Auth: already working, server-side session gives me [user id field]

## Plans

| Plan   | Price   | Interval | Stripe price ID env var    |
|--------|---------|----------|----------------------------|
| Free   | $0      || (none, absence of a sub)   |
| [Pro]  | [$12]   | monthly  | STRIPE_PRICE_PRO_MONTHLY   |

Products and prices are created in the Stripe dashboard, not in code. Code
reads price IDs from env vars so test mode and live mode never share IDs.

## Environment variables (never hardcoded, never client-exposed)

```bash
STRIPE_SECRET_KEY=sk_test_...       # server only; NEXT_PUBLIC_ prefix is a bug
STRIPE_WEBHOOK_SECRET=whsec_...     # from `stripe listen` locally, dashboard in prod
STRIPE_PRICE_PRO_MONTHLY=price_...
APP_URL=http://localhost:3000       # used to build success/cancel/return URLs
```

## Data model

Add billing columns to [profiles/users table], written ONLY by the webhook:

```sql
alter table profiles
  add column stripe_customer_id text unique,
  add column subscription_status text not null default 'none',
    -- 'none' | 'active' | 'past_due' | 'canceled'
  add column subscription_price_id text,
  add column current_period_end timestamptz;

create table stripe_events (
  id text primary key,           -- Stripe event id, makes handling idempotent
  type text not null,
  received_at timestamptz not null default now()
);
```

## Flow 1: start a subscription (POST /api/billing/checkout, authenticated)

1. Load the user. If `stripe_customer_id` is null, create a Stripe customer
   with the user's email and store the id.
2. Create a Checkout Session: `mode: "subscription"`, the price ID from env,
   `customer` set to the stored id, success URL `[APP_URL]/billing?status=success`,
   cancel URL `[APP_URL]/billing`.
3. Redirect to the session URL. Do NOT write subscription_status here.
   The success page may say "thanks, activating" but access flips only when
   the webhook lands.

## Flow 2: manage a subscription (POST /api/billing/portal, authenticated)

1. Create a Billing Portal session for the stored customer id, return URL
   `[APP_URL]/billing`. Redirect to it.
2. Build no cancel, upgrade, or card-update UI of our own. The portal does all
   of it, including proration and dunning emails configured in the dashboard.

## Flow 3: the webhook (POST /api/webhooks/stripe)

1. Verify the signature with STRIPE_WEBHOOK_SECRET against the RAW request
   body (framework body parsing must be disabled for this route). Reject on
   failure with 400.
2. Insert the event id into `stripe_events`; on conflict, return 200 and stop.
   Stripe retries and replays; every handler runs at-least-once.
3. Handle exactly these types, ignore all others with a 200:
   - `checkout.session.completed`
   - `customer.subscription.updated`
   - `customer.subscription.deleted`
   - `invoice.payment_failed`
4. On each handled event, fetch the subscription fresh from the Stripe API by
   id and write status, price id, and `current_period_end` from that response.
   Events can arrive out of order; the API is current, the event may not be.

```typescript
// shape of the only billing write in the codebase
async function syncSubscription(subscriptionId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId);
  await db.updateProfileByCustomerId(sub.customer as string, {
    subscription_status: mapStatus(sub.status), // active/past_due/canceled -> our enum
    subscription_price_id: sub.items.data[0].price.id,
    current_period_end: new Date(sub.current_period_end * 1000),
  });
}
```

## Gating access

- Server-side check: user is paid when `subscription_status = 'active'` OR
  (`'past_due'` and within a [3-day] grace window). Client-side checks are
  cosmetic only.

## Verification (test mode, before calling this done)

1. `stripe listen --forward-to localhost:3000/api/webhooks/stripe` running.
2. Subscribe with test card 4242 4242 4242 4242; confirm status flips to
   active via the webhook log, not the redirect.
3. Cancel through the portal; confirm status flips to canceled with zero
   clicks in our own UI.
4. Replay an event with `stripe events resend`; confirm the second delivery
   is a no-op.

Adaptation notes:

  • Multiple paid tiers: add rows to the plans table and env vars per price. The webhook logic does not change, because it reads whatever price the subscription actually has.
  • Annual billing is a second price on the same product, not a second product. Pass the chosen price ID into the same checkout route.
  • Trials: set trial_period_days on the Checkout Session and treat Stripe's trialing status as active in mapStatus. Do not build your own trial clock next to Stripe's.
  • The mistake: writing billing state from the success redirect "just as a backup." Now you have two writers, they disagree, and the bug only shows up when a real customer's card fails. One writer. It's the webhook.
  • Going live is three env var swaps (key, webhook secret, price IDs) plus registering the production webhook endpoint in the dashboard. If live behaves differently than test, check which mode's price ID you shipped before debugging anything else.