Why You Can't Just Check the Client Side
Here's a mistake that catches a lot of developers the first time they build a payment flow: they check whether the payment succeeded on the client side, inside the browser, and use that to unlock access or fulfill the order.
The problem is that the client can lie. A user can close the browser tab mid-redirect, JavaScript can throw an error before your success handler runs, or — in the worst case — someone can just fake the success response in their browser. The client-side outcome of a Stripe checkout tells you what the user's browser experienced. It doesn't tell you what Stripe's servers actually recorded.
Stripe webhooks are how you know what actually happened. When a payment succeeds, fails, or a subscription changes, Stripe sends an HTTP POST directly to your server with the authoritative record of that event. Your server-side fulfillment logic — provisioning access, sending confirmation emails, updating the database — should always be driven by these webhook events, not by what the browser told you.
The right mental model
The browser redirect after a successful payment is just a user experience convenience. The webhook is the source of truth. Build your fulfillment logic to run from the webhook, and treat the success redirect as cosmetic.
Setting Up Your Webhook Endpoint in the Stripe Dashboard
The first thing you need is a publicly accessible URL that Stripe can POST to. During development that usually means a tunnel — more on that in a moment. For now, assume you have a URL like https://yourapp.com/webhooks/stripe.
Go to Developers → Webhooks in the Stripe Dashboard
You'll find separate sections for test mode and live mode. Make sure you're in the right one. Test mode events only fire against your test API keys, and vice versa — this bites people constantly.
Add your endpoint URL and select events
Click "Add endpoint" and enter your URL. Then choose which events to subscribe to. Don't subscribe to everything — you'll drown in noise. Subscribe to the specific events your business logic actually needs to respond to.
Copy your signing secret
After creating the endpoint, Stripe shows you a "Signing secret" starting with whsec_. Store this in your environment variables. You'll need it to verify incoming requests.
The full list of every event Stripe can fire lives in the Stripe event types reference. It's long. Bookmark it and search for what you need rather than reading it top to bottom.
The Events You'll Actually Use
Most Stripe integrations revolve around a small handful of events. Here's what each one actually means and when you need it.
payment_intent.succeeded
A one-time payment was captured successfully. This is the event you listen to for e-commerce checkouts — the customer paid, the money moved, now fulfill the order. The PaymentIntent object inside the event has the amount, currency, customer ID, and any metadata you attached at checkout time.
payment_intent.payment_failed
A payment attempt failed — card declined, insufficient funds, expired card. You probably want to email the customer or surface a message in your UI. Don't cancel the order immediately though; the customer might retry with a different card.
invoice.paid
A subscription invoice was paid. This fires on the initial subscription and on every renewal. This is how you extend access for another billing period. If you're building a subscription product, this event is probably your most important one.
customer.subscription.created
A new subscription started. This is where you provision access in your system — create the user's subscription record, set their plan level, send the welcome email. Note that invoice.paid also fires at the same time for the first payment, so be careful not to double-provision.
customer.subscription.deleted
A subscription was canceled — either by the customer, by you, or because payment failed too many times. This is where you revoke access. Don't lock them out immediately if they canceled but still have time left in their billing period — check cancel_at_period_end.
charge.refunded
A charge was refunded, either partially or fully. Update your order status, adjust inventory if applicable, and potentially send the customer a confirmation. If you're logging revenue, this is how you record a reversal.
The subscription lifecycle gap most devs miss
invoice.payment_failed (note: invoice, not payment_intent) is what fires when a subscription renewal fails. Stripe will retry according to your dunning settings, and eventually fire customer.subscription.deleted if it keeps failing. You should listen to both and handle them — one to warn the customer, one to revoke access.
Handling Events in Node.js with Express
The structure of a Stripe webhook handler is pretty standard: verify the signature, parse the event type, handle each event you care about, return 200. Here's a clean Express route that shows the full pattern.
Before we get to the code — one thing that catches people off-guard. Stripe's signature verification requires the raw request body, not the parsed JSON. If you use express.json() globally, it consumes the raw body and Stripe's verification will fail. You need to use express.raw() specifically for the Stripe webhook route, and apply express.json() everywhere else.
// server.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Apply express.json() globally for all other routes
app.use((req, res, next) => {
if (req.originalUrl === '/webhooks/stripe') {
next(); // Skip JSON parsing for Stripe route
} else {
express.json()(req, res, next);
}
});
// Stripe webhook route — must use express.raw()
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Check for duplicate — idempotency guard
const alreadyProcessed = await db.events.findOne({ stripeEventId: event.id });
if (alreadyProcessed) {
return res.json({ received: true }); // Already handled, ack and exit
}
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object;
await fulfillOrder(paymentIntent);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object;
await notifyPaymentFailed(paymentIntent);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
await extendSubscriptionAccess(invoice);
break;
}
case 'customer.subscription.created': {
const subscription = event.data.object;
await provisionSubscription(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await revokeSubscriptionAccess(subscription);
break;
}
case 'charge.refunded': {
const charge = event.data.object;
await handleRefund(charge);
break;
}
default:
// Unknown event type — log it and move on
console.log(`Unhandled event type: ${event.type}`);
}
// Record that we processed this event
await db.events.insert({ stripeEventId: event.id, processedAt: new Date() });
res.json({ received: true });
}
);The switch statement pattern is idiomatic Stripe — you'll see it in basically every Stripe webhook example. Each case handles a specific event type and falls through to the final res.json({ received: true }). Return 200 fast; do heavy work asynchronously if needed.
Verifying the Stripe Signature — Always
Your webhook endpoint is a public URL. Anyone on the internet can POST JSON to it and pretend to be Stripe. Without signature verification, an attacker could send a fake payment_intent.succeeded event to your server and get free access to whatever you're selling.
Stripe's signature scheme works like this: when Stripe sends a webhook, it computes an HMAC-SHA256 of the raw request body combined with a timestamp, using your endpoint's signing secret as the key. It sends that signature in the Stripe-Signature header. You recompute the same hash on your end and compare them. If they match, the request came from Stripe and the body wasn't tampered with.
The timestamp in the header is there to prevent replay attacks — someone capturing a legitimate Stripe request and resending it later. Stripe rejects events where the timestamp is more than 5 minutes old by default.
The stripe.webhooks.constructEvent() call in the code above does all of this for you. It takes the raw body (not parsed JSON — raw bytes), the signature header, and your signing secret. If the signature is invalid or the timestamp is too old, it throws an error. That's why you wrap it in a try/catch and return a 400 on failure.
The raw body gotcha — again
If you pass constructEvent() a parsed JSON object instead of the raw buffer, it will always fail verification. The HMAC is computed over the exact bytes Stripe sent — once Express parses and re-serializes the body, even whitespace differences will break it. Use express.raw({ type: 'application/json' }) on the webhook route only.
Idempotency — Because Stripe Will Send Events More Than Once
Stripe guarantees at-least-once delivery, not exactly-once. In practice, most events arrive once, but if your server returns a non-2xx response, is slow to respond, or has a network hiccup, Stripe will retry. According to Stripe's best practices, they retry up to 24 hours with exponential backoff.
This means your handlers must be idempotent — running the same handler twice for the same event should produce the same result as running it once. The simplest way to achieve this is to track processed event IDs in your database and skip any event you've already handled. Stripe event IDs look like evt_1Nx4Xj2eZvKYlo2CrKmOLDfZ and are globally unique.
// Idempotency check — do this early, before any business logic
const existing = await db.processedWebhookEvents.findOne({
where: { stripeEventId: event.id }
});
if (existing) {
// We already handled this. Acknowledge it and move on.
return res.json({ received: true });
}
// ... do your business logic here ...
// Record this event as processed
await db.processedWebhookEvents.create({
stripeEventId: event.id,
eventType: event.type,
processedAt: new Date()
});Keep a processed_webhook_events table with a unique index on the Stripe event ID, and you're covered. You can also clean up old records after 30 days — Stripe won't retry anything that old.
Testing with the Stripe CLI
The Stripe CLI is genuinely one of the best developer tools in the payments world. It lets you forward Stripe events to your local server without needing a public URL or ngrok.
Once you've installed the CLI and authenticated with stripe login, run this to start forwarding events:
stripe listen --forward-to localhost:3000/webhooks/stripe
The CLI will print a webhook signing secret starting with whsec_ — use that as your STRIPE_WEBHOOK_SECRET env var during development. It's different from your production signing secret, so don't mix them up.
In a separate terminal, you can trigger specific events without going through the full checkout flow:
# Trigger a successful payment stripe trigger payment_intent.succeeded # Trigger a subscription deletion stripe trigger customer.subscription.deleted # Trigger an invoice payment failure stripe trigger invoice.payment_failed
This is dramatically faster than going through a test checkout every time you want to test your webhook handler. The payloads Stripe sends via stripe trigger are realistic test data — not exactly what real events look like for your specific customers, but good enough to test your handler logic. When you receive one, paste the JSON body into JsonParser.ai to explore the event structure before writing your handler code — it's much easier than reading through a deeply nested object in a terminal.
Mistakes That Will Cause You Pain Later
These are the patterns that work fine in development and break in production in subtle ways.
Processing heavy work synchronously inside the handler
Stripe expects your endpoint to respond within a few seconds. If you're sending emails, calling external APIs, or doing anything that might be slow, do it in a background job. Acknowledge the webhook immediately with a 200, then push the work onto a queue (BullMQ, SQS, whatever you have). Timing out causes Stripe to retry, which causes more load, which causes more timeouts — a fun spiral to debug at 2am.
Only listening to the "happy path" events
A lot of developers handle payment_intent.succeeded and call it done. Then a subscription renewal fails six months later and nobody gets notified, access never gets revoked, and your revenue reports are wrong. Map out every state transition in your business model and make sure there's a webhook handler for it.
Mixing up test mode and live mode
Test mode webhooks will never fire to your production endpoint, and live mode webhooks won't fire to your test endpoint. You need separate endpoint registrations for each environment, with separate signing secrets. Store them as environment variables and make sure your deployment pipeline sets the right one per environment. Using the wrong secret in the wrong environment means every verification call throws an error and you get flooded with 400s.
Not logging the raw event
Log every incoming event — at minimum the event ID and type — before you do anything with it. When something goes wrong in production (and it will), you want to be able to replay events from the Stripe dashboard and see exactly what arrived. Stripe keeps 30 days of event history, but your logs will tell you which events actually hit your server and what state your system was in at the time.
The Stripe webhooks documentation covers all of this in depth and is well worth a full read before you go to production. It's one of the better-written pieces of API documentation out there.