Stripe Integration Guide

Subscriptions, optional one-time credit top-ups, and webhook handlers that sync Stripe state into Postgres.

Overview

You get:

  • Subscriptions: Monthly recurring payments (Pro, Enterprise)
  • One-time payments: Credit top-ups
  • Webhooks: Automatic tier/credit updates

Setup

1. Create Stripe Account

  1. Go to stripe.com
  2. Create account and complete verification
  3. Start in Test Mode

2. Create Products

In Stripe Dashboard → Products → Add Product:

Pro Subscription

Name: Pro Plan
Description: 2,500 credits/month, access to GPT-4o & Claude
Price: $49.00 USD
Billing: Recurring → Monthly

Credit Top-up

Name: Credit Top-up
Description: 1,000 additional credits
Price: $20.00 USD
Billing: One-time

Enterprise Subscription

Name: Enterprise Plan
Description: 10,000 credits/month, all models, team features
Price: $199.00 USD
Billing: Recurring → Monthly

3. Get API Keys

In Developers → API Keys:

  • Publishable Key: pk_test_xxx (client-side, optional unless you add Stripe Elements)
  • Secret Key: sk_test_xxx (server-side only)

4. Configure Webhook

In Developers → Webhooks → Add Endpoint:

Endpoint URL:

https://your-domain.com/api/webhooks/stripe

Events to listen:

  • checkout.session.completed
  • customer.subscription.deleted
  • invoice.paid (subscription renewals / monthly credit refresh)

Copy the Signing Secret: whsec_xxx

5. Environment Variables

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_PRO_PRICE_ID=price_xxx
STRIPE_TOPUP_PRICE_ID=price_xxx
STRIPE_ENTERPRISE_PRICE_ID=price_xxx

Code Architecture

Stripe Client (src/lib/stripe/client.ts)

import Stripe from "stripe";

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

export { stripe };

Checkout Action (src/app/actions/stripe.ts)

Creates Stripe Checkout sessions:

export async function generateCheckoutLink(
  priceId: string,
  type: "subscription" | "one-time"
) {
  // 1. Get current user
  // 2. Create/get Stripe customer
  // 3. Create checkout session with metadata
  // 4. Return checkout URL
}

Key features:

  • Links Stripe customer to user ID
  • Stores payment type in metadata
  • Redirects back to billing page

Webhook Handler (src/app/api/webhooks/stripe/route.ts)

Handles Stripe events:

export async function POST(req: NextRequest) {
  // 1. Get raw body (critical for signature verification)
  const body = await req.text();

  // 2. Verify signature
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET
  );

  // 3. Handle events
  switch (event.type) {
    case "checkout.session.completed":
      // Update user tier/credits
      break;
    case "customer.subscription.deleted":
      // Downgrade to free
      break;
  }
}

Payment Flows

Subscription Flow

User clicks "Upgrade to Pro"
         ↓
generateCheckoutLink("subscription")
         ↓
Redirect to Stripe Checkout
         ↓
User completes payment
         ↓
Stripe sends checkout.session.completed
         ↓
Webhook updates profile:
  - tier: "pro"
  - credits: 2500 (+ any top-up carry-over)
  - stripe_customer_id: "cus_xxx"
         ↓
Redirect to /dashboard/billing?success=true

Top-up Flow

User clicks "Top Up Credits"
         ↓
generateCheckoutLink("one-time")
         ↓
Redirect to Stripe Checkout
         ↓
User completes payment
         ↓
Stripe sends checkout.session.completed
         ↓
Webhook adds credits:
  - credits: existing + 1000
         ↓
Redirect to /dashboard/billing?success=true

Cancellation Flow

User cancels in Stripe Customer Portal
         ↓
Stripe sends customer.subscription.deleted
         ↓
Webhook downgrades profile:
  - tier: "free"
  - credits: 100

Webhook Security

Signature Verification

Critical: Always use req.text() for raw body:

// CORRECT
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, signature, secret);

// WRONG - will fail signature verification
const body = await req.json();

Idempotency

Handle duplicate webhooks:

// Option 1: Check if already processed
const { data: existing } = await supabase
  .from("stripe_events")
  .select("id")
  .eq("event_id", event.id)
  .single();

if (existing) return; // Already processed

// Option 2: Use upsert with conflict handling
await supabase
  .from("profiles")
  .upsert({ id: userId, tier: "pro" })
  .eq("id", userId);

Testing

Test Mode

Use test API keys (sk_test_, pk_test_).

Test Cards

Card NumberResult
4242 4242 4242 4242Success
4000 0000 0000 9995Decline
4000 0000 0000 32203D Secure

Test Webhooks Locally

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

# Login
stripe login

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

# Copy the webhook signing secret and use it

Trigger Test Events

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted

Going Live

1. Switch to Live Mode

In Stripe Dashboard, toggle "Test mode" off.

2. Update Keys

Replace all sk_test_ and pk_test_ with live keys.

3. Create Live Products

Re-create products in live mode (they don't transfer).

4. Update Webhook Endpoint

Add live webhook endpoint with production URL.

5. Test Live Flow

Make a small real payment to verify everything works.

Troubleshooting

Webhook 400 Error

  • Check signature secret matches
  • Ensure using req.text() not req.json()

Customer Not Created

  • Verify stripe_customer_id is saved to profile
  • Check Supabase RLS policies allow updates

Credits Not Updated

  • Check webhook event type handling
  • Verify user_id in session metadata
  • Check Supabase connection in webhook

Subscription Shows Active but Tier is Free

  • Webhook may have failed
  • Manually verify in Stripe Dashboard
  • Re-trigger webhook event