← Taking the weekly build challenge?

Stripe Integration for Vibe Coders

⚠️ Read This First — The #1 Gotcha

AI gets Stripe wrong in 3 specific ways:

  1. 1. It uses req.json() instead of req.text() — breaking signature verification silently
  2. 2. It skips stripe.webhooks.constructEvent() — leaving your endpoint spoofable
  3. 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.

1

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
);
2

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!
);
3

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-js

Environment 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

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Click Add endpoint
  3. URL: https://yourdomain.com/api/webhook
  4. Select events to listen to (see minimum list below)
  5. 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 = false

Full 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

1

Enable the portal in Stripe Dashboard

Dashboard → Settings → Billing → Customer portal → Enable. Configure which features users can access (cancel, upgrade, payment method update).

2

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 }
});
3

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 });
}
4

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>
5

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 login

Forward webhook events to localhost (gives you a temp whsec_ for testing)

stripe listen --forward-to localhost:3000/api/webhook

Trigger a specific event without making a real payment

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

Replay 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.

FIX: Check Stripe Dashboard → Events. Compare the URL exactly (including trailing slashes).
stripe listen --forward-to localhost:3000/api/webhook

Error Pattern 02

Incorrect Signing Secret

Symptom: Webhook returns 400 Bad Request on every request.

FIX: Signing secrets are endpoint-specific. Use the secret for THAT exact URL.
Look for 'Webhook signature verification failed' in server logs.

Error Pattern 03

Payload Corruption

Symptom: Verification always fails even with the correct secret.

FIX: Read raw body using req.text(), never parse as JSON before verification.
Check: typeof body === 'string'

Error Pattern 04

Metadata Null

Symptom: Payment works, but you don't know who paid.

FIX: Pass userId in session metadata, not just as a URL query param.
Log event.data.object.metadata in your handler.

Error Pattern 05

Test Keys in Production

Symptom: Real cards are declined, only test cards work.

FIX: Ensure STRIPE_SECRET_KEY is sk_live_ in production environment variables.
Check Dashboard status (orange banner = Test Mode).

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.completed

The CLI will provide a temporary whsec_ secret for local testing.

Standard Test Cards

Card NumberScenarioDetail
4242 4242 4242 4242✅ SuccessAny CVC / Future date
4000 0025 0000 3155🔐 3D SecureRequired for EU cards
4000 0000 0000 9995❌ DeclinedGeneric decline
4000 0000 0000 0341❌ Auth FailCard 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.

1

Switch to live keys (sk_live_ / pk_live_)

Replace all test keys in your production environment. Never commit them to git.

2

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.

3

Set STRIPE_WEBHOOK_SECRET to the live endpoint secret

The signing secret is endpoint-specific. Your test whsec_ will NOT work for live events.

4

Webhook endpoint is publicly accessible (not localhost)

Stripe cannot reach localhost. Your production URL must be live before you create the endpoint.

5

Raw body is preserved before JSON parsing

Webhook signature verification fails if body is parsed first. Use req.text(), never req.json().

6

JWT auth is disabled for the webhook endpoint

Supabase Edge Functions require JWT by default — Stripe cannot provide one. Disable it.

7

All relevant Stripe events are subscribed in Dashboard

Minimum: checkout.session.completed, payment_intent.succeeded, customer.subscription.updated, invoice.payment_failed.

8

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