What Are Stripe Webhooks? How Your AI-Built App Gets Paid in Real Time
TL;DR
Stripe webhooks are notifications that Stripe sends to YOUR server when payment events happen — a customer paid, a subscription renewed, a charge failed. Without webhooks, your app has no idea that money arrived. It's like selling something online but never checking the mailbox for the check. When you ask your AI to "add Stripe payments," it generates a webhook endpoint — a URL on your server that Stripe calls with event data. The critical part most AI-generated code gets wrong: you must verify webhook signatures or anyone can fake a payment event and get free access to your product. One function call (stripe.webhooks.constructEvent) is the difference between a working payment system and an open door for fraud.
Why Stripe Webhooks Matter for Your AI-Built App
You asked Claude to "add Stripe payments to my SaaS app." The AI generated checkout code, an API route, and somewhere in there — a webhook handler. Maybe it's a file called /api/webhooks/stripe.ts or /api/webhook.js. You're staring at it, and it looks like gibberish. Event types, signature verification, raw body parsing. What is all this?
Here's what you need to understand: the checkout page is only half the payment flow. Stripe Checkout handles collecting the credit card and charging the customer. But your app — the thing you actually built — still doesn't know the payment happened. The checkout page lives on Stripe's servers. Your app lives on yours. They need to talk.
That's what webhooks do. They're Stripe's way of calling your server and saying: "Hey, this person just paid. Do your thing."
Without webhooks, you'd have to keep asking Stripe "did anyone pay yet?" over and over — every second, for every user. That's called polling, and it's wasteful, slow, and unreliable. Webhooks flip the model: Stripe tells you when something happens, so your code can react instantly.
If you've already read our guide on what Stripe is or how Stripe Checkout works, webhooks are the missing piece that makes the whole payment flow actually function in production.
The Doorbell Mental Model
Forget HTTP callbacks and event-driven architecture for a minute. Think about a doorbell.
Without a doorbell (no webhooks): You'd have to walk to the front door every 10 seconds and check if someone's there. All day. Even when nobody's coming. That's polling — and it's exactly as exhausting and wasteful as it sounds.
With a doorbell (webhooks): You go about your day. When someone shows up, the doorbell rings. You answer it. Done.
In this analogy:
- The visitor = a payment event (someone paid, a subscription renewed, a charge failed)
- The doorbell = Stripe's webhook system
- You answering the door = your webhook endpoint processing the event
- Your house = your server
The doorbell doesn't care what you do when you answer — maybe you sign for a package, maybe you tell someone to go away. That's your business logic. The doorbell just lets you know someone's at the door.
Same with Stripe webhooks. Stripe doesn't care what your app does with the event. It just delivers the notification. Your code decides: grant access, send a receipt, update a database, send a welcome email — whatever your app needs to do when money moves.
How Stripe Webhooks Actually Work (Step by Step)
Here's the full flow from "customer clicks Pay" to "your app reacts":
- Customer completes checkout — They enter their card on your Stripe Checkout page and click Pay.
- Stripe processes the payment — Stripe talks to the bank, verifies the card, moves the money. This happens entirely on Stripe's side.
- Stripe fires a webhook event — Stripe sends an HTTP POST request to a URL you registered (your webhook endpoint). The body of this request contains a JSON object describing what happened.
- Your server receives the event — Your webhook endpoint gets the POST request. It contains the event type (like
checkout.session.completed) and all the relevant data. - Your code verifies the signature — Before doing anything, you check that this request actually came from Stripe and wasn't faked.
- Your code handles the event — Based on the event type, your app does the right thing: activate the subscription, send a receipt, update the database.
- Your server responds with 200 — You tell Stripe "got it" by returning a 200 status code. If you don't, Stripe assumes delivery failed and retries.
Steps 4–7 are what happen inside your webhook handler. That's the code your AI generated. Let's look at what it actually looks like.
The Webhook Events You'll Actually Use
Stripe can send over 200 different event types. You don't need to handle all of them. Here are the ones that matter for most AI-built apps:
| Event Type | When It Fires | What Your App Should Do |
|---|---|---|
checkout.session.completed |
Customer finishes the checkout page | Grant access, create user record, send welcome email |
payment_intent.succeeded |
A one-time payment is confirmed | Fulfill the order, update payment status |
customer.subscription.created |
A new subscription starts | Activate the user's plan, set feature flags |
customer.subscription.updated |
Subscription plan changes (upgrade/downgrade) | Update the user's plan level and available features |
customer.subscription.deleted |
Subscription is cancelled | Downgrade to free tier, revoke premium access |
invoice.payment_succeeded |
A recurring subscription payment goes through | Extend access period, send receipt |
invoice.payment_failed |
A subscription payment attempt fails | Notify the customer, start grace period, retry logic |
charge.refunded |
A payment is refunded | Revoke access, update order status |
For a basic SaaS app, you probably need just three: checkout.session.completed (someone signed up), invoice.payment_failed (their card was declined on renewal), and customer.subscription.deleted (they cancelled). Everything else is refinement.
The Webhook Code Your AI Generated — Line by Line
When you asked your AI to add Stripe payments, it probably generated something like this. Let's look at an Express.js webhook handler — the most common pattern AI produces — and understand every piece:
// webhook handler — Express.js
// This is the endpoint Stripe calls when payment events happen
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// CRITICAL: This route needs the RAW body, not parsed JSON
// This MUST come before any express.json() middleware
app.post('/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
// Step 1: Verify this request actually came from Stripe
try {
event = stripe.webhooks.constructEvent(
req.body, // raw body — NOT parsed JSON
sig, // signature from the request header
webhookSecret // your webhook signing secret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Step 2: Handle the event based on its type
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// A customer completed checkout — grant them access
const customerEmail = session.customer_details.email;
const customerId = session.customer;
console.log(`Payment received from ${customerEmail}`);
// TODO: Update your database, activate the user's plan
await activateSubscription(customerId, customerEmail);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
// A subscription payment failed — notify the customer
const customerId = invoice.customer;
console.log(`Payment failed for customer ${customerId}`);
// TODO: Send email, start grace period
await handleFailedPayment(customerId);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// Subscription was cancelled — revoke access
const customerId = subscription.customer;
console.log(`Subscription cancelled for ${customerId}`);
// TODO: Downgrade to free tier
await deactivateSubscription(customerId);
break;
}
default:
// Unexpected event type — log it but don't crash
console.log(`Unhandled event type: ${event.type}`);
}
// Step 3: Tell Stripe you received the event
// ALWAYS return 200 quickly — don't do heavy work before this
res.status(200).json({ received: true });
}
);
app.listen(3000, () => console.log('Server running on port 3000'));
Let's break down the three critical parts:
Part 1: The Raw Body Requirement
See that express.raw({ type: 'application/json' })? That's the line that trips up the most AI-generated code. Normally, Express parses incoming JSON automatically with express.json(). But Stripe's signature verification needs the raw, unparsed body — the exact bytes that Stripe sent. If Express parses it into a JavaScript object first, the signature check fails every time.
This is the #1 reason webhook handlers break. If you see a 400 error in your Stripe webhook logs, check this first.
Part 2: Signature Verification
stripe.webhooks.constructEvent() does three things in one call: it takes the raw body, the signature header from the request, and your webhook secret — then it cryptographically verifies that this event genuinely came from Stripe and hasn't been tampered with. If any of those don't match, it throws an error and your handler rejects the request.
Part 3: The Switch Statement
This is your business logic — what your app actually does when events arrive. The pattern is always the same: check the event type, extract the relevant data from event.data.object, and take action. The default case is important — Stripe might send events you didn't expect, and your handler shouldn't crash when that happens.
The Security Mistake That Lets Anyone Fake a Payment
⚠️ Critical Security Warning: If your webhook handler does NOT verify signatures, anyone who discovers your webhook URL can send fake payment events. They could POST a checkout.session.completed event and your app would grant them premium access — without ever paying a cent. This is not theoretical. It happens to real apps in production.
Your webhook URL is just a URL. It's not secret. It might be https://yourapp.com/api/webhooks/stripe. Anyone can send a POST request to it. The only thing that proves a request actually came from Stripe is the signature verification.
Here's what signature verification does in plain English:
- When you set up your webhook in Stripe's dashboard, Stripe gives you a webhook signing secret (starts with
whsec_) - Every time Stripe sends a webhook event, it uses that secret to create a cryptographic signature and includes it in the
stripe-signatureheader - Your code uses the same secret to verify the signature matches the request body
- If someone sends a fake request, they don't have your secret, so the signature won't match — and your handler rejects it
The function call is one line: stripe.webhooks.constructEvent(rawBody, sig, webhookSecret). There is no excuse to skip it. If your AI generated webhook code without this line, add it immediately. If you're reading this and thinking "my app doesn't do this" — stop everything and fix it now. Go read our deep dive on webhook security for the full picture.
Store your webhook signing secret in environment variables, just like your Stripe API keys. Never hardcode it.
Stripe Webhooks in Next.js (App Router)
If your AI built your app with Next.js instead of Express, the webhook handler looks slightly different. Here's the App Router version:
// app/api/webhooks/stripe/route.js
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(req) {
// Get the raw body — Next.js App Router gives you this directly
const body = await req.text();
const sig = headers().get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Grant access, update database
await handleCheckoutComplete(session);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
// Notify customer, start grace period
await handlePaymentFailed(invoice);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// Revoke premium access
await handleSubscriptionCancelled(subscription);
break;
}
default:
console.log(`Unhandled event: ${event.type}`);
}
return NextResponse.json({ received: true });
}
The key difference: Next.js App Router gives you the raw body via req.text() — no middleware configuration needed. This is actually easier than Express because you don't have to worry about body parser conflicts. But if you're using the older Pages Router with API routes, you'll need to disable the default body parser — ask your AI specifically about that.
Setting Up Stripe Webhooks (The Complete Flow)
Your AI can write the code, but you need to register the webhook endpoint with Stripe. Here's exactly how:
Step 1: Write Your Webhook Handler
Use one of the code examples above (Express or Next.js). Your AI already generated this — make sure it includes signature verification.
Step 2: Deploy Your Handler
Your webhook endpoint needs to be reachable from the internet. Stripe can't call localhost:3000. Deploy your app to Vercel, Railway, or wherever you host — the URL needs to be public.
Step 3: Register in Stripe Dashboard
- Go to Stripe Dashboard → Developers → Webhooks
- Click "Add endpoint"
- Enter your endpoint URL (e.g.,
https://yourapp.com/api/webhooks/stripe) - Select the events you want to listen for (start with
checkout.session.completed,invoice.payment_failed,customer.subscription.deleted) - Click "Add endpoint"
- Copy the Signing secret (starts with
whsec_) — this goes in your environment variables asSTRIPE_WEBHOOK_SECRET
Step 4: Test with Stripe CLI
For local development, you don't need to deploy every time you change your webhook handler. The Stripe CLI can forward events to your local machine:
# Install the Stripe CLI
brew install stripe/stripe-cli/stripe
# Login 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 webhook signing secret for local testing. Use that in your .env file during development.
Prompts I Would Type to Set This Up Right
When you ask your AI to implement Stripe webhooks, be specific. Vague prompts produce broken code. Here are the exact prompts I'd use:
Prompt I Would Type
"Add a Stripe webhook handler to my Express app. It should verify webhook signatures using the STRIPE_WEBHOOK_SECRET environment variable, handle checkout.session.completed, invoice.payment_failed, and customer.subscription.deleted events. Use express.raw() middleware ONLY on the webhook route so it doesn't break my other JSON routes. Return 200 immediately after handling."
Prompt I Would Type
"Add a Stripe webhook endpoint to my Next.js App Router app at /api/webhooks/stripe/route.js. Use req.text() for the raw body, verify signatures with stripe.webhooks.constructEvent, and handle these events: checkout.session.completed (activate user subscription in database), invoice.payment_failed (send notification, set grace period), customer.subscription.deleted (downgrade to free tier). Include error handling and logging."
Prompt I Would Type
"My Stripe webhooks are returning 400 errors. I'm using Express with express.json() globally. Fix the webhook route to use express.raw() for just the /api/webhooks/stripe endpoint while keeping express.json() for all other routes. Show me the exact middleware ordering."
Prompt I Would Type
"Add idempotency handling to my Stripe webhook handler. Store processed event IDs in the database and skip duplicate events. I don't want a customer to get charged twice or activated twice if Stripe retries a webhook delivery."
Notice how specific these are. Every prompt mentions signature verification, specific event types, and the framework being used. Don't just say "add Stripe webhooks" — that's how you get code that works in a demo but breaks in production.
5 Mistakes AI-Generated Webhook Code Makes
I've seen these in almost every AI-generated Stripe integration. Check your code for all five:
Mistake 1: No Signature Verification
We've covered this, but it bears repeating. Some AI-generated code skips signature verification entirely — it just parses the JSON body and trusts whatever it receives. In development, this works fine. In production, it's a security hole. Always use stripe.webhooks.constructEvent().
Mistake 2: Body Parser Conflicts
The most frustrating bug. Your AI adds app.use(express.json()) at the top of your Express app (as it should for normal routes), but this parses the webhook request body before signature verification can access the raw bytes. The fix: apply express.raw() to your webhook route specifically, and make sure it's defined before the global express.json() middleware.
// CORRECT: Raw body for webhook route, JSON for everything else
app.post('/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
webhookHandler
);
// This comes AFTER the webhook route
app.use(express.json());
Mistake 3: Not Handling Duplicate Events
Stripe might send the same event more than once. Network hiccup? Your server responded slowly? Stripe retried. If your handler runs the same logic twice — activating a subscription twice, sending two welcome emails — you've got a bug. The fix is idempotency: store each event ID, and skip events you've already processed.
// Check for duplicate events
const existingEvent = await db.query(
'SELECT id FROM processed_events WHERE stripe_event_id = $1',
[event.id]
);
if (existingEvent.rows.length > 0) {
// Already processed this event — skip it
return res.status(200).json({ received: true });
}
// Process the event, then record it
await handleEvent(event);
await db.query(
'INSERT INTO processed_events (stripe_event_id) VALUES ($1)',
[event.id]
);
Mistake 4: Doing Heavy Work Before Responding
Stripe expects a response within 20 seconds. If your webhook handler sends emails, makes API calls to other services, generates PDFs, or does complex database operations before responding — and those tasks take too long — Stripe will think delivery failed and retry. The event arrives again. Now you might process it twice.
The fix: return 200 immediately and do the heavy work asynchronously. Queue the work with a background job system, or use Promise.resolve().then() to process after responding.
Mistake 5: Ignoring Failed Payment Events
Your AI probably handled checkout.session.completed — the happy path. But what about invoice.payment_failed? When a customer's card expires or gets declined during subscription renewal, Stripe sends this event. If you ignore it, the customer loses access without warning, you lose revenue, and nobody knows why. Handle the failure path: notify the customer, start a grace period, offer to update their payment method.
Why Your Webhook Handler Needs to Be Idempotent
"Idempotent" sounds like a word from a computer science textbook. In plain English, it means: running the same operation twice produces the same result as running it once.
Why does this matter? Because Stripe can and will send the same event multiple times. Here's when that happens:
- Your server was slow to respond (took more than 20 seconds)
- Your server returned a non-200 status code (even accidentally)
- There was a network issue between Stripe and your server
- Stripe's internal systems experienced an issue and replayed events
- You manually resent an event from the Stripe Dashboard for debugging
Stripe retries webhook deliveries for up to 3 days using exponential backoff. That's a lot of potential retries.
If your "activate subscription" handler runs twice for the same event, and you're inserting a new database row each time, you might end up with duplicate records. If you're sending a welcome email, the customer gets two. If you're granting credits, they get double credits.
The solution is simple: store the event.id from every processed webhook event, and check for it before processing. Each Stripe event has a unique ID like evt_1Nq2k2LkdIwHu7ixLWbTHqHY. If you've seen it before, skip it.
Testing Webhooks Without Deploying
One of the most frustrating things about webhook development: you can't test them locally by default. Stripe needs to reach your server over the internet. You can't give Stripe http://localhost:3000 as your endpoint URL — it won't work.
The Stripe CLI solves this. It creates a tunnel from Stripe's servers to your local machine:
# Terminal 1: Start your local server
npm run dev
# Terminal 2: Forward Stripe events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Output: Your webhook signing secret is whsec_abc123...
# Use this secret in your .env for local testing
# Terminal 3: Trigger specific events for testing
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
The stripe trigger command sends test events with realistic fake data. This lets you verify your handler processes each event type correctly without making real payments.
Pro tip: the webhook signing secret from stripe listen is different from your production webhook secret. Use separate environment variables or a .env.local file so you don't accidentally use the local secret in production.
Your Stripe Webhook Production Checklist
Before you ship your payment flow to real users, verify every item on this list:
- ✅ Signature verification is present —
stripe.webhooks.constructEvent()is called on every request - ✅ Raw body is preserved — Your webhook route receives the unparsed request body (not JSON-parsed)
- ✅ Webhook secret is in environment variables — Not hardcoded, not in version control, stored in your hosting provider's env config
- ✅ Handler returns 200 quickly — Heavy processing happens asynchronously, after the response
- ✅ Duplicate events are handled — Event IDs are stored and checked before processing
- ✅ Failed payments are handled —
invoice.payment_failedtriggers customer notification and grace period logic - ✅ Subscription cancellation is handled —
customer.subscription.deletedrevokes access appropriately - ✅ Stripe CLI testing passed — All event types have been tested locally with
stripe trigger - ✅ Endpoint is registered in Stripe Dashboard — With the correct URL and event types selected
- ✅ Webhook logs are monitored — You can see successes and failures in Stripe Dashboard → Webhooks → your endpoint
If any item is unchecked, your payment system has a gap. Fix it before launch. Real money is at stake.
What Happens When Your Webhook Endpoint Goes Down
Good news: Stripe doesn't just try once and give up. If your webhook endpoint returns an error (anything other than a 2xx status code) or times out, Stripe retries automatically.
The retry schedule uses exponential backoff:
- 1st retry: ~1 minute after the initial attempt
- 2nd retry: ~5 minutes
- 3rd retry: ~30 minutes
- Continues up to ~24 hours between attempts
- Total retry window: up to 3 days
So if your server goes down for a quick deploy (a few minutes), you won't miss any events. They'll arrive as soon as your server is back up. But if your endpoint is broken for 3+ days — consistently returning errors — those events are gone.
You can also see retry status and manually resend events from Stripe Dashboard → Webhooks → [your endpoint] → Event deliveries. This is your first stop when debugging "why didn't my app react to this payment."
If you're building anything with authentication like Better Auth, your webhook needs to be as reliable as your auth flow — because payment events often trigger access changes.
Frequently Asked Questions
What is a Stripe webhook in plain English?
A Stripe webhook is a notification that Stripe sends to your server when something happens with a payment. Instead of your app constantly asking Stripe "did anyone pay yet?", Stripe calls YOUR server and says "hey, someone just paid." It's like a doorbell — you don't stand at the door all day checking. The doorbell rings when someone arrives.
What happens if I don't verify webhook signatures?
Without signature verification, anyone who knows your webhook URL can send fake payment events to your server. They could craft a POST request that says payment_intent.succeeded and your app would grant access, ship a product, or activate a subscription — even though no money actually changed hands. Signature verification is a single function call (stripe.webhooks.constructEvent) and it's non-negotiable for production apps.
Why does my Stripe webhook keep failing with a 400 error?
The most common cause is that your framework is parsing the request body as JSON before Stripe's signature verification can check the raw body. Stripe needs the raw, unmodified request body to verify the signature. In Express, this means using express.raw({type: 'application/json'}) on your webhook route instead of express.json(). In Next.js, you need to use req.text() in the App Router or disable the default body parser in the Pages Router.
Does Stripe retry webhook events if my server is down?
Yes. Stripe retries failed webhook deliveries for up to 3 days using exponential backoff — it waits longer between each attempt (1 minute, then 5 minutes, then 30 minutes, and so on up to 24 hours). If your server is briefly down for a deploy or restart, you won't miss events. But if your endpoint is permanently broken or doesn't exist, events will be dropped after 3 days of retries.
What's the difference between a Stripe webhook and a Stripe API call?
A Stripe API call is when YOUR code talks to Stripe — creating a checkout session, refunding a payment, or fetching a customer record. A webhook is when STRIPE talks to your code — notifying you that a payment succeeded, a subscription was cancelled, or an invoice failed. API calls go from you to Stripe. Webhooks go from Stripe to you. Most payment flows need both.
How do I test Stripe webhooks during development?
Stripe provides a CLI tool (stripe listen) that forwards webhook events to your local server. Install the Stripe CLI, run stripe listen --forward-to localhost:3000/api/webhooks/stripe, and it gives you a temporary webhook signing secret for local testing. You can also trigger test events with stripe trigger payment_intent.succeeded. This lets you develop and debug webhook handlers without deploying to a live server.
What to Learn Next
Now that you understand how Stripe webhooks work, here's where to go deeper:
- Webhook Security — Deep dive into securing webhook endpoints beyond just Stripe, including replay attack prevention and IP allowlisting
- Better Auth — How authentication connects to payments — your webhook often needs to update user access levels
- API Key Management — Your Stripe secret key and webhook signing secret are just two of the secrets your app needs to protect
- Server Actions — If you're using Next.js, understand how server-side code runs alongside your webhook handlers
- Background Jobs — The right way to handle heavy processing after webhook events, so you can return 200 quickly
Webhooks aren't just a Stripe concept. Once you understand this pattern — one service notifying another when something happens — you'll see it everywhere: GitHub webhooks when code is pushed, Clerk webhooks when users sign up, webhooks in general across the entire web. The pattern you just learned scales to every integration your AI-built app will ever need.