TL;DR: Stripe webhooks are HTTP POST requests Stripe sends to your server when payment events happen — charges succeed, subscriptions renew, payments fail. Your app needs a webhook endpoint to receive these events, verify they actually came from Stripe (signature verification), and process them. Without webhooks, your app has no reliable way to know when someone paid you. AI tools will generate the basic code, but they consistently miss security checks, retry handling, and the raw body requirement that breaks everything in production.
Why AI Coders Need This
Here's the scenario that bites every vibe coder building a SaaS: You prompt Claude or Cursor to "add Stripe payments to my app." It generates a beautiful checkout flow. You test it with Stripe's test card number (4242 4242 4242 4242). It works perfectly. You deploy to production, share the link, and someone actually pays.
Then nothing happens.
The customer's card was charged. The money is in your Stripe account. But your app doesn't know. The user's account still shows "Free Plan." No confirmation email went out. They email you asking what's going on.
This is the webhook gap — and it catches almost every new builder. When a customer pays through Stripe Checkout, Stripe redirects them back to your success page. But what if the customer closes their browser before the redirect? What if their internet drops? What if they hit the back button? Your success page redirect is a courtesy, not a guarantee.
Webhooks are the guarantee. Stripe sends a direct server-to-server notification that a payment happened — no browser involved, no customer action required. According to Stripe's own documentation, webhooks are the recommended way to handle all payment lifecycle events. Not optional. Not "nice to have." Essential.
If you're building anything that takes money — a SaaS, a course platform, a marketplace, a membership site — you need Stripe webhooks. And you need to understand what they're doing, because the code AI generates for them has consistent, dangerous blind spots.
Real Scenario
Let's say you're building a SaaS app with a monthly subscription. You've got the landing page, the sign-up flow, and now you need to handle payments. Here's the kind of prompt you'd give your AI coding assistant:
Prompt You'd Give Your AI
Add Stripe subscription payments to my Next.js app.
I need:
1. A checkout button that creates a Stripe Checkout Session
2. A webhook endpoint that listens for successful payments
3. When payment succeeds, update the user's plan to "pro" in the database
4. Handle subscription cancellations too — set them back to "free"
5. Make sure it works even if the customer closes the browser after paying
Use the app router (route handlers, not pages).
This is a solid prompt. You're telling the AI exactly what you need, including the critical "even if the customer closes the browser" requirement that forces it to implement webhooks properly. Let's look at what a good AI assistant would generate.
What AI Generated
Here's the webhook endpoint that AI typically produces — this is the version after we've cleaned up the common mistakes (we'll cover those in the "What AI Gets Wrong" section):
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { db } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
// Step 1: Get the raw body — MUST be raw text, not parsed JSON
const body = await request.text();
// Step 2: Get the Stripe signature from the request headers
const signature = (await headers()).get('stripe-signature');
if (!signature) {
return Response.json({ error: 'Missing signature' }, { status: 400 });
}
// Step 3: Verify the webhook actually came from Stripe
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('⚠️ Webhook signature verification failed:', err);
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// Step 4: Handle the specific event types you care about
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await db.user.update({
where: { id: userId },
data: {
plan: 'pro',
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
},
});
console.log(`✅ Activated pro plan for user ${userId}`);
}
break;
}
case 'invoice.payment_succeeded': {
// Recurring payment — subscription renewed
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
await db.user.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: { plan: 'pro', planExpiresAt: null },
});
console.log(`✅ Renewed subscription ${subscriptionId}`);
break;
}
case 'customer.subscription.deleted': {
// Subscription cancelled or expired
const subscription = event.data.object as Stripe.Subscription;
await db.user.updateMany({
where: { stripeCustomerId: subscription.customer as string },
data: { plan: 'free', stripeSubscriptionId: null },
});
console.log(`❌ Cancelled subscription for customer ${subscription.customer}`);
break;
}
case 'invoice.payment_failed': {
// Payment failed — card declined, expired, etc.
const invoice = event.data.object as Stripe.Invoice;
console.warn(`⚠️ Payment failed for invoice ${invoice.id}`);
// You might want to email the customer or flag their account
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
// Still return 200 — we don't want Stripe to retry if our DB had a hiccup
// Log the error and investigate manually
}
// Step 5: Return 200 immediately
return Response.json({ received: true });
}
And you'll need this Next.js config to prevent the body from being automatically parsed:
// next.config.js — No special config needed in App Router!
// The App Router doesn't auto-parse request bodies in route handlers.
// If you were using Pages Router, you'd need:
// export const config = { api: { bodyParser: false } };
Understanding Each Part
The Big Picture: What's Actually Happening
When a customer pays you through Stripe, here's the full sequence of events. Understanding this flow is the key to understanding why webhooks exist:
- Customer clicks "Subscribe" → Your app creates a Stripe Checkout Session and redirects the customer to Stripe's hosted payment page
- Customer enters card details → This happens entirely on Stripe's servers. Your app never sees the card number (that's PCI compliance handled for you)
- Stripe processes the payment → Stripe charges the card, server-to-server with the bank
- Stripe sends a webhook → An HTTP POST request to your
/api/webhooks/stripeendpoint containing a JSON payload describing what happened - Your endpoint processes it → Verifies the signature, reads the event, updates your database
- Your endpoint returns 200 → Tells Stripe "got it, thanks"
- Meanwhile → Stripe redirects the customer to your success page (if they're still there)
Steps 4–6 happen independently of step 7. That's the whole point. The webhook doesn't care what the customer's browser is doing.
The Webhook Signing Secret
You have two Stripe secrets, and mixing them up is a common source of confusion:
STRIPE_SECRET_KEY(starts withsk_live_orsk_test_) — This is your API key. You use it to call Stripe's API: create checkout sessions, fetch customer data, issue refunds. See our secrets management guide for how to store it safely.STRIPE_WEBHOOK_SECRET(starts withwhsec_) — This is your webhook signing secret. Stripe uses it to sign webhook payloads. You use it to verify that a webhook actually came from Stripe.
Where to find your webhook secret: Stripe Dashboard → Developers → Webhooks → Click your endpoint → "Signing secret" section. The Stripe CLI gives you a different temporary secret for local testing.
Signature Verification: The Security Gate
Your webhook endpoint is a public URL. Literally anyone on the internet can send a POST request to it. Without signature verification, a malicious actor could send a fake checkout.session.completed event to https://yourapp.com/api/webhooks/stripe and your app would happily upgrade their account to "pro" for free.
Stripe prevents this by signing every webhook payload. Here's how it works:
- Stripe takes the raw JSON payload and a timestamp
- Stripe creates a signature using your webhook secret and HMAC-SHA256
- Stripe includes the signature in the
stripe-signatureheader - Your endpoint uses
stripe.webhooks.constructEvent()to verify the signature matches - If it doesn't match → reject with 400. If it does → safe to process.
This is the same concept behind API authentication — proving identity before granting access. The difference is that here, Stripe is proving its identity to your server.
The Raw Body Requirement
This trips up more vibe coders than any other webhook issue. The signature verification requires the exact raw bytes of the request body. If your framework automatically parses the body as JSON before your code runs, the formatting changes — whitespace gets removed, key order might shift — and the signature check fails.
In Next.js App Router, request.text() gives you the raw string. In the Pages Router, you'd need to disable the built-in body parser. In Express, you'd use express.raw({type: 'application/json'}) on the webhook route specifically.
This is why AI-generated webhook code often works with stripe trigger in the CLI but fails with real webhooks — the test payload might happen to match the parsed version, but real payloads won't.
Common Stripe Events You'll Handle
Stripe sends over 200 event types. Here are the ones that matter for most SaaS apps:
| Event | When It Fires | What You Do |
|---|---|---|
checkout.session.completed |
Customer finishes checkout | Activate subscription, send welcome email |
invoice.payment_succeeded |
Recurring payment succeeds | Extend subscription, reset usage limits |
invoice.payment_failed |
Card declined on renewal | Email customer, show warning in app |
customer.subscription.deleted |
Subscription cancelled/expired | Downgrade to free plan |
customer.subscription.updated |
Plan changed (upgrade/downgrade) | Update plan in database |
charge.refunded |
You issued a refund | Revoke access, update records |
charge.dispute.created |
Customer filed a chargeback | Flag account, gather evidence |
You don't need to handle all of them. Start with the first four — they cover 90% of SaaS payment flows.
What Happens When Webhooks Fail
Stripe doesn't just try once and give up. When your endpoint returns a non-200 status code (or doesn't respond within 20 seconds), Stripe retries with exponential backoff:
- First retry: ~1 minute later
- Subsequent retries: Spacing out over the next few hours
- Final attempt: Up to 3 days after the original event
- Total retries: Up to 12 attempts for live mode events
After all retries are exhausted, the event is marked as failed in your Stripe Dashboard. Stripe also emails you when your endpoint has a high failure rate. If your endpoint is consistently failing, Stripe will eventually disable it to protect both of you.
This retry behavior is why idempotency matters. Your webhook handler might receive the same event multiple times. If checkout.session.completed fires twice for the same session, you don't want to give the customer two subscriptions or send two welcome emails. Use the event ID or the Stripe object ID to check whether you've already processed it.
What AI Gets Wrong About Stripe Webhooks
⚠️ These aren't edge cases. These are issues we see in AI-generated webhook code consistently, across Claude, ChatGPT, Cursor, and Copilot. Check your code for every one of these.
1. Skipping Signature Verification Entirely
The most dangerous mistake. Some AI-generated handlers just parse the JSON body and start processing events — no signature check at all. This means anyone who discovers your webhook URL can send fake events. A crafted checkout.session.completed payload gives them a free subscription. Always use stripe.webhooks.constructEvent() — it's the single line that separates a secure endpoint from an exploitable one.
2. Using request.json() Instead of request.text()
This is the #1 "it works locally but breaks in production" bug. AI often generates code that parses the body with request.json() or JSON.parse() and then tries to verify the signature against the re-stringified JSON. The signature is computed over the raw bytes. Any transformation — even invisible whitespace changes — breaks verification. Always read the raw body with request.text() first, verify it, then parse if needed (though constructEvent already parses it for you).
3. Doing Heavy Work Before Returning 200
AI loves to generate sequential code: receive webhook → send email → update database → call another API → generate PDF → then return 200. If any of those steps takes more than 20 seconds, Stripe marks the delivery as failed and retries. The correct pattern: return 200 immediately after verification, then process asynchronously. In production, that means a background job queue — but even a simple Promise that runs after the response is better than blocking.
4. Not Handling Duplicate Events
AI-generated handlers assume every event is unique. They aren't. Stripe explicitly says webhooks can be delivered more than once. If your handler creates a database record every time it receives checkout.session.completed, a retry creates a duplicate. Use upsert instead of create, or store the Stripe event ID and skip events you've already processed.
5. Hardcoding the Webhook Secret
We've seen AI generate code with the webhook secret right in the source file: const secret = "whsec_abc123...". This gets committed to GitHub, pushed to a public repo, and now anyone can forge webhook signatures for your endpoint. Your webhook secret belongs in an environment variable, managed like any other secret. Read our secrets management guide for the proper setup.
6. Using the Wrong Secret for the Wrong Environment
Stripe gives you different webhook secrets for test mode and live mode — and different secrets for the CLI vs. Dashboard-configured endpoints. AI doesn't know which environment you're deploying to. If you copy your CLI's whsec_ secret into your production environment, every real webhook will fail signature verification. Match the secret to the endpoint.
How to Debug Stripe Webhooks
Webhook bugs are uniquely frustrating because the error happens on your server, triggered by an external service, with no browser DevTools to inspect. Here's your debugging toolkit:
Step 1: Use the Stripe CLI for Local Testing
# Install the Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe
# Log in to your Stripe account
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger a test event
stripe trigger checkout.session.completed
The stripe listen command gives you a temporary signing secret (whsec_...). Use that as your STRIPE_WEBHOOK_SECRET during local development. The CLI shows you every event Stripe sends and whether your endpoint accepted or rejected it.
Step 2: Check the Stripe Dashboard
Go to Stripe Dashboard → Developers → Webhooks → Click your endpoint → Recent deliveries. You'll see every webhook attempt, the payload Stripe sent, the response your server returned, and the HTTP status code. A 400 with "Invalid signature" tells you it's a raw body or wrong secret issue. A 500 tells you your handler code crashed.
Step 3: Add Targeted Logging
// Temporary debug logging — remove before production
console.log('Webhook received:', {
type: event.type,
id: event.id,
created: new Date(event.created * 1000).toISOString(),
livemode: event.livemode,
});
// Log the full object for the event you're debugging
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
console.log('Session details:', {
id: session.id,
customer: session.customer,
subscription: session.subscription,
metadata: session.metadata,
payment_status: session.payment_status,
});
}
Step 4: Ask AI to Debug with Context
When you're stuck, paste the error into your AI assistant with full context:
Debug Prompt
My Stripe webhook endpoint is returning 400.
Here's the error from my server logs: [paste error]
Here's my webhook handler code: [paste code]
Here's the Stripe Dashboard showing the failed delivery: [describe what you see]
I'm using Next.js App Router. What's wrong?
Nine times out of ten, it's one of: wrong signing secret, parsed body instead of raw body, or the endpoint URL doesn't match what's configured in Stripe. If you're deploying with Docker, make sure the environment variable is being passed through to the container.
Step 5: Replay Failed Events
In the Stripe Dashboard, you can resend any webhook event. This is invaluable when your endpoint was broken and real payments came through — fix the code, deploy, then replay the failed events to process them. No data is lost as long as Stripe still has the events (they're retained for 30 days).
Setting Up Webhooks for Production
Local testing with the Stripe CLI is great for development, but production requires a proper webhook endpoint registered in Stripe's Dashboard:
- Deploy your app with the webhook endpoint accessible at a public URL (e.g.,
https://yourapp.com/api/webhooks/stripe) - Go to Stripe Dashboard → Developers → Webhooks → Add endpoint
- Enter your endpoint URL and select the events you want to receive (start with
checkout.session.completed,invoice.payment_succeeded,invoice.payment_failed,customer.subscription.deleted) - Copy the signing secret (
whsec_...) and add it asSTRIPE_WEBHOOK_SECRETin your production environment variables - Send a test webhook from the Dashboard to confirm your endpoint responds with 200
Only select the events you actually handle. Subscribing to everything generates unnecessary traffic and noise in your logs. You can always add more event types later.
What's Next
You now understand how Stripe communicates with your app after a payment. Here's where to go from here:
Frequently Asked Questions
Stripe webhooks are HTTP POST requests that Stripe sends to your server whenever a payment event happens — a charge succeeds, a subscription renews, a payment fails. Instead of your app constantly checking Stripe for updates, Stripe pushes the event to a URL you configure. Think of it as Stripe tapping your app on the shoulder and saying "hey, something just happened."
Your checkout page redirect is unreliable. The customer might close the browser, lose internet, or hit the back button before your success page loads. The webhook fires server-to-server — it doesn't depend on the customer's browser at all. It's the only guaranteed way to know a payment actually completed. Stripe themselves say: never rely solely on the client-side redirect.
The signing secret (starts with whsec_) is a key Stripe gives you when you create a webhook endpoint. Stripe uses it to sign every webhook payload. Your server uses the same secret to verify the signature — proving the webhook actually came from Stripe and wasn't faked by someone who found your endpoint URL. It's different from your Stripe API key. You'll find it in the Stripe Dashboard under Developers → Webhooks → your endpoint.
Stripe retries failed webhooks with exponential backoff — starting every few minutes, then spacing out over up to 3 days. After all retries are exhausted, the event is marked as failed. You can see failed deliveries in the Stripe Dashboard under Webhooks → your endpoint → Recent deliveries. Common causes: your server was down, your endpoint returned a non-200 status, or signature verification failed.
Use the Stripe CLI: run stripe listen --forward-to localhost:3000/api/webhooks/stripe and it creates a local tunnel. The CLI gives you a temporary webhook signing secret (whsec_...) for local testing. You can then trigger test events with stripe trigger checkout.session.completed. No need for ngrok or exposing your machine to the internet.