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
- Go to stripe.com
- Create account and complete verification
- 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.completedcustomer.subscription.deletedinvoice.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 Number | Result |
|---|---|
| 4242 4242 4242 4242 | Success |
| 4000 0000 0000 9995 | Decline |
| 4000 0000 0000 3220 | 3D 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()notreq.json()
Customer Not Created
- Verify
stripe_customer_idis 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