Stripe Integration for Vibe Coders
⚠️ Read This First — The #1 Gotcha
AI gets Stripe wrong in 3 specific ways:
- 1. It uses
req.json()instead ofreq.text()— breaking signature verification silently - 2. It skips
stripe.webhooks.constructEvent()— leaving your endpoint spoofable - 3. It processes the DB write inside the webhook handler synchronously — Stripe times out at 30s, retries, you get double charges
All three are fixed in the code below. Scroll to What AI Gets Wrong for full details.
You told the AI to "add Stripe payments." It generated the code. The frontend looks great. But money isn't moving — and there's no error message telling you why.
This guide covers the exact failure points that bite vibe coders: silent webhook failures, the Supabase JWT conflict that causes mysterious 401s, Customer Portal setup, and the Test-to-Live checklist that catches the gaps before your customers do.
What AI Gets Wrong About Stripe
Every AI tool — Cursor, Lovable, Bolt, ChatGPT — makes the same 3 Stripe mistakes. Here's exactly what breaks and why.
Wrong Body Parsing: req.json() instead of req.text()
Stripe signs the raw bytes of the request body. The moment you parse it as JSON, the byte order changes — and signature verification fails with a cryptic 400 error.
❌ What AI generates
// BREAKS signature verification
const body = await req.json();
stripe.webhooks.constructEvent(
JSON.stringify(body), // already mutated
sig,
secret
);✅ What actually works
// Raw text preserves the signature
const body = await req.text();
stripe.webhooks.constructEvent(
body, // original bytes
sig,
secret
);Missing Signature Verification
AI often skips constructEvent() entirely and just parses the body directly. This means anyone can POST fake payment events to your webhook and trigger database writes.
❌ Spoofable endpoint
// No verification — anyone can trigger this
const event = await req.json();
if (event.type === 'checkout.session.completed') {
await grantAccess(event.data.object);
}✅ Verified endpoint
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
// Throws if signature invalid
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);Wrong Webhook Timing — Synchronous DB Writes
Stripe expects a 200 OK within 30 seconds. If your DB write + email send takes longer, Stripe retries — and you get duplicate grants, double emails, or race conditions.
❌ Blocks response, causes retries
// Blocks for 2-10s, Stripe may retry
await db.update(subscription);
await sendWelcomeEmail(user);
await createOnboardingTask(user);
return NextResponse.json({ received: true });✅ Respond immediately, process async
// Respond immediately
const response = NextResponse.json({ received: true });
// Queue heavy work (Inngest, QStash, etc.)
await queue.send('process-payment', { event });
return response;Subscription vs One-Time Payment — Which Do You Need?
The biggest architectural decision before you write a line of Stripe code. Getting this wrong means rewriting your entire integration.
Subscription
- → Monthly/annual recurring billing
- → Access continues while paid, revokes when cancelled
- → Events:
subscription.updated,invoice.payment_failed - → Needs Customer Portal for self-service cancel/upgrade
Checkout mode
mode: 'subscription'Use for: SaaS tools, dashboards, content platforms, recurring services
One-Time Payment
- → Customer pays once, gets permanent access
- → Simpler webhook: only
checkout.session.completed - → No subscription management, no churn logic
- → Just flip a boolean in DB on payment
Checkout mode
mode: 'payment'Use for: digital products, templates, reports, lifetime deals
💡 Decision Rule
If users need to cancel or upgrade themselves → subscription + Customer Portal. If it's a one-and-done purchase → one-time payment. When in doubt, start with one-time. You can always add subscriptions later.
Quick Setup
Install Library
npm install stripe @stripe/stripe-jsEnvironment Variables
Add to .env.local
# From Stripe Dashboard → Developers → API Keys
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# From Stripe Dashboard → Webhooks → endpoint → Signing secret
STRIPE_WEBHOOK_SECRET=whsec_...Use sk_test_ and pk_test_ during development. Never commit these to git.
Checkout Route
File: app/api/checkout/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, userId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: ${process.env.NEXT_PUBLIC_URL}/success,
cancel_url: ${process.env.NEXT_PUBLIC_URL}/pricing,
// Pass userId so your webhook knows who paid
metadata: { userId },
});
return NextResponse.json({ url: session.url });
}Webhook Setup — The #1 Failure Point
The frontend checkout works. The webhook is where money actually gets recorded in your database. This is where most vibe-coded Stripe integrations silently break.
⚠️ Warning
Stripe calls your server after payment. If your webhook endpoint returns anything other than 200, or doesn't exist, Stripe retries — but your database never gets updated. No error on the frontend. Customer is charged. Your DB shows nothing.
Signature Verification
File: app/api/webhook/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ⚠️ CRITICAL: Next.js must NOT parse the body before we verify
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const body = await req.text(); // raw text, not json()
const headersList = await headers();
const sig = headersList.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'Webhook signature failed' }, { status: 400 });
}
// Respond immediately — process heavy work async
const response = NextResponse.json({ received: true });
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
// Queue async — don't await heavy ops here
break;
}
return response;
}Dashboard Registration
- Go to Stripe Dashboard → Developers → Webhooks
- Click Add endpoint
- URL:
https://yourdomain.com/api/webhook - Select events to listen to (see minimum list below)
- Copy the Signing secret (
whsec_...) to your env vars
Minimum events to subscribe:
- checkout.session.completed
- payment_intent.succeeded
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_failed
The Supabase JWT Gotcha
The single most reported Stripe issue from vibe coders using Supabase. Two days of debugging for what is a one-line fix.
🔴 The Problem
Supabase Edge Functions require a JWT token by default. Stripe's webhook POST request has no JWT. Result: 401 Unauthorized on every call, even if your code is perfect.
Error in logs: { "message": "Missing or invalid JWT" }
The Fix
Option A: Disable JWT in Dashboard (Recommended)
Supabase Dashboard → Edge Functions → your webhook function → Settings → JWT verification: Disabled
Option B: CLI Configuration
File: supabase/config.toml
[functions.stripe-webhook]
verify_jwt = falseFull Edge Function
File: supabase/functions/stripe-webhook/index.ts
import Stripe from 'https://esm.sh/stripe@14';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
Deno.serve(async (req) => {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')!);
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), { status: 400 });
}
// Process event...
return new Response(JSON.stringify({ received: true }));
});Supabase uses constructEventAsync because Deno uses the Web Crypto API.
Stripe Customer Portal Setup
Let users cancel, upgrade, or update their payment method themselves — without you building any of that UI. This is the feature most vibe coders skip and then regret when support requests pile up.
💡 What the Customer Portal gives you for free
- ✓ Cancel subscription
- ✓ Upgrade/downgrade plan
- ✓ Update payment method
- ✓ Download invoices
- ✓ View billing history
5-Step Setup
Enable the portal in Stripe Dashboard
Dashboard → Settings → Billing → Customer portal → Enable. Configure which features users can access (cancel, upgrade, payment method update).
Store the Stripe customer ID when user first pays
// In your checkout handler, save the customer ID
const session = await stripe.checkout.sessions.retrieve(sessionId);
await db.users.update({
where: { id: userId },
data: { stripe_customer_id: session.customer }
});Create the portal session route
File: app/api/customer-portal/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; // or your auth
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const user = await getCurrentUser(); // get authenticated user
if (!user?.stripe_customer_id) {
return NextResponse.json({ error: 'No subscription found' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}Add the button to your billing page
async function openPortal() {
const res = await fetch('/api/customer-portal', { method: 'POST' });
const { url } = await res.json();
window.location.href = url; // redirect to Stripe's hosted portal
}
<button onClick={openPortal}>Manage Billing</button>Handle the cancellation webhook
When a user cancels via the portal, Stripe fires customer.subscription.deleted. Handle this in your webhook to revoke access:
case 'customer.subscription.deleted':
const sub = event.data.object;
await db.subscriptions.update({
where: { stripe_subscription_id: sub.id },
data: { status: 'cancelled', ends_at: new Date(sub.current_period_end * 1000) }
});
break;Stripe CLI Debugger
Stop guessing. The Stripe CLI lets you replay events, inspect payloads, and forward live events to localhost. This single tool eliminates 80% of webhook debugging.
Install & authenticate
brew install stripe/stripe-cli/stripe
stripe loginForward webhook events to localhost (gives you a temp whsec_ for testing)
stripe listen --forward-to localhost:3000/api/webhookTrigger a specific event without making a real payment
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failedReplay a past event from Stripe Dashboard (get the event ID first)
stripe events resend evt_1234567890abcdef --webhook-endpoint=we_xxxxx💡 Pro Tip
Run stripe listen in one terminal and your dev server in another. The CLI prints every event + response in real time — including the exact error if your handler throws.
Silent Failures & Debugging
These are the failures that make you think everything is fine until a customer emails you asking where their access is.
Error Pattern 01
Endpoint URL Mismatch
Symptom: Dashboard shows 200 success, but your DB is empty.
Error Pattern 02
Incorrect Signing Secret
Symptom: Webhook returns 400 Bad Request on every request.
Error Pattern 03
Payload Corruption
Symptom: Verification always fails even with the correct secret.
Error Pattern 04
Metadata Null
Symptom: Payment works, but you don't know who paid.
Error Pattern 05
Test Keys in Production
Symptom: Real cards are declined, only test cards work.
Testing Flows
Stripe CLI Pipeline
Terminal
# Install & Login
brew install stripe/stripe-cli/stripe
stripe login
# Forward events
stripe listen --forward-to localhost:3000/api/webhook
# Trigger success event
stripe trigger checkout.session.completedThe CLI will provide a temporary whsec_ secret for local testing.
Standard Test Cards
| Card Number | Scenario | Detail |
|---|---|---|
| 4242 4242 4242 4242 | ✅ Success | Any CVC / Future date |
| 4000 0025 0000 3155 | 🔐 3D Secure | Required for EU cards |
| 4000 0000 0000 9995 | ❌ Declined | Generic decline |
| 4000 0000 0000 0341 | ❌ Auth Fail | Card attaches, payment fails |
Test-to-Live Checklist
8 steps to go live safely. Do these in order — skip one and you'll be debugging at midnight.
Switch to live keys (sk_live_ / pk_live_)
Replace all test keys in your production environment. Never commit them to git.
Create a separate live webhook endpoint
Test and live webhooks are separate in Stripe Dashboard. Create a new live endpoint and copy the new whsec_ signing secret.
Set STRIPE_WEBHOOK_SECRET to the live endpoint secret
The signing secret is endpoint-specific. Your test whsec_ will NOT work for live events.
Webhook endpoint is publicly accessible (not localhost)
Stripe cannot reach localhost. Your production URL must be live before you create the endpoint.
Raw body is preserved before JSON parsing
Webhook signature verification fails if body is parsed first. Use req.text(), never req.json().
JWT auth is disabled for the webhook endpoint
Supabase Edge Functions require JWT by default — Stripe cannot provide one. Disable it.
All relevant Stripe events are subscribed in Dashboard
Minimum: checkout.session.completed, payment_intent.succeeded, customer.subscription.updated, invoice.payment_failed.
Run a real payment with a real card (small amount)
Use a $1 test price with a real card. Check Stripe Dashboard → Events log within 24h to confirm webhook delivery.
Production Payments
Ship your payment flow in 7 days
Join the challenge to build a real product with a bulletproof payment integration.
Start Shipping