Validate Stripe payment success on your backend using webhooks. Listen for payment_intent.succeeded or checkout.session.completed events, verify the webhook signature with stripe.webhooks.constructEvent() using the raw request body, then fulfill the order. Never trust frontend redirects alone — webhooks are the source of truth.
Why You Must Verify Payments Server-Side
A customer landing on your success page does not prove they paid. They could have bookmarked the URL, shared it, or the redirect could fail. The only reliable way to confirm payment is through Stripe webhooks. When a payment succeeds, Stripe sends a signed HTTP POST to your server. You verify the signature using stripe.webhooks.constructEvent() with the raw request body, then fulfill the order. This guarantees you only fulfill orders that Stripe has actually charged.
Prerequisites
- A Stripe account with a webhook endpoint configured in the Dashboard
- Node.js 18+ with stripe and express packages installed
- Your Stripe webhook signing secret (whsec_) from Dashboard → Developers → Webhooks
- The Stripe CLI installed for local testing (optional but recommended)
Step-by-step guide
Create the webhook endpoint with raw body parsing
Create the webhook endpoint with raw body parsing
The webhook endpoint MUST receive the raw request body (not parsed JSON) for signature verification. Use express.raw() for this route and register it BEFORE express.json().
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);3const app = express();45// CRITICAL: Webhook route must use raw body6app.post(7 '/webhook',8 express.raw({ type: 'application/json' }),9 (req, res) => {10 const sig = req.headers['stripe-signature'];11 let event;1213 try {14 event = stripe.webhooks.constructEvent(15 req.body,16 sig,17 process.env.STRIPE_WEBHOOK_SECRET18 );19 } catch (err) {20 console.error('Webhook signature verification failed:', err.message);21 return res.status(400).send(`Webhook Error: ${err.message}`);22 }2324 // Handle the event25 switch (event.type) {26 case 'payment_intent.succeeded':27 handlePaymentSuccess(event.data.object);28 break;29 case 'checkout.session.completed':30 handleCheckoutComplete(event.data.object);31 break;32 default:33 console.log(`Unhandled event type: ${event.type}`);34 }3536 res.json({ received: true });37 }38);3940// Regular routes use JSON parsing — AFTER webhook route41app.use(express.json());Expected result: The endpoint receives webhook events, verifies the signature, and processes them.
Handle payment success events
Handle payment success events
Implement the handler functions that run when a payment is confirmed. Use the PaymentIntent or Checkout Session metadata to match the payment to your internal order.
1function handlePaymentSuccess(paymentIntent) {2 console.log('Payment succeeded:', paymentIntent.id);3 console.log('Amount:', paymentIntent.amount, paymentIntent.currency);4 console.log('Metadata:', paymentIntent.metadata);56 // TODO: Fulfill the order7 // - Update order status in your database8 // - Send confirmation email9 // - Grant access to digital product10 // - Trigger shipping for physical product11}1213function handleCheckoutComplete(session) {14 console.log('Checkout completed:', session.id);15 console.log('Payment status:', session.payment_status);16 console.log('Customer email:', session.customer_details?.email);1718 if (session.payment_status === 'paid') {19 // Fulfill the order20 }21}Expected result: Payment details are logged and your fulfillment logic runs only for verified payments.
Configure the webhook in Stripe Dashboard
Configure the webhook in Stripe Dashboard
Go to Dashboard → Developers → Webhooks → Add endpoint. Enter your server's webhook URL and select the events to listen for.
1// Dashboard settings:2// Endpoint URL: https://yoursite.com/webhook3// Events to send:4// - payment_intent.succeeded5// - payment_intent.payment_failed6// - checkout.session.completed7// - checkout.session.expired89// Copy the Signing secret (whsec_...) to your environment variablesExpected result: Stripe sends selected events to your endpoint. The signing secret is available for verification.
Test locally with the Stripe CLI
Test locally with the Stripe CLI
Install the Stripe CLI and forward webhook events to your local server for testing.
1# Install Stripe CLI (macOS)2brew install stripe/stripe-cli/stripe34# Login to your Stripe account5stripe login67# Forward events to your local server8stripe listen --forward-to localhost:4000/webhook910# The CLI prints a webhook signing secret (whsec_...).11# Use THIS secret for local testing, not the Dashboard one.1213# In another terminal, trigger a test event:14stripe trigger payment_intent.succeededExpected result: The CLI forwards events to localhost:4000/webhook and your handler processes them.
Make your webhook idempotent
Make your webhook idempotent
Stripe may send the same event more than once (network retries). Use the event ID to ensure you don't process the same event twice.
1const processedEvents = new Set(); // Use a database in production23app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {4 // ... signature verification ...56 // Idempotency check7 if (processedEvents.has(event.id)) {8 return res.json({ received: true }); // Already processed9 }10 processedEvents.add(event.id);1112 // Process the event...13 res.json({ received: true });14});Expected result: Duplicate webhook deliveries are safely ignored without re-processing the order.
Complete working example
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);34const app = express();56// Webhook endpoint — MUST be before express.json()7app.post(8 '/webhook',9 express.raw({ type: 'application/json' }),10 async (req, res) => {11 const sig = req.headers['stripe-signature'];12 let event;1314 try {15 event = stripe.webhooks.constructEvent(16 req.body,17 sig,18 process.env.STRIPE_WEBHOOK_SECRET19 );20 } catch (err) {21 console.error('Webhook signature failed:', err.message);22 return res.status(400).send(`Webhook Error: ${err.message}`);23 }2425 switch (event.type) {26 case 'checkout.session.completed': {27 const session = event.data.object;28 if (session.payment_status === 'paid') {29 console.log('Order fulfilled for session:', session.id);30 // TODO: update database, send email, grant access31 }32 break;33 }34 case 'payment_intent.succeeded': {35 const pi = event.data.object;36 console.log('Payment confirmed:', pi.id, pi.amount, pi.currency);37 break;38 }39 case 'payment_intent.payment_failed': {40 const pi = event.data.object;41 console.log('Payment failed:', pi.id, pi.last_payment_error?.message);42 break;43 }44 default:45 console.log(`Unhandled event: ${event.type}`);46 }4748 res.json({ received: true });49 }50);5152// Regular middleware — AFTER webhook route53app.use(express.static('public'));54app.use(express.json());5556// Checkout Session creation57app.post('/create-checkout-session', async (req, res) => {58 const session = await stripe.checkout.sessions.create({59 mode: 'payment',60 line_items: [{61 price_data: {62 currency: 'usd',63 product_data: { name: 'Widget' },64 unit_amount: 2000,65 },66 quantity: 1,67 }],68 success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,69 cancel_url: `${req.headers.origin}/cancel`,70 });71 res.json({ url: session.url });72});7374app.listen(4000, () => console.log('Server on port 4000'));Common mistakes when validating Stripe payment success on backend
Why it's a problem: Using express.json() before the webhook route
How to avoid: express.json() parses the body, but constructEvent requires the raw Buffer. Register the webhook route with express.raw() BEFORE any express.json() middleware.
Why it's a problem: Not verifying the webhook signature
How to avoid: Always call stripe.webhooks.constructEvent() with the raw body, stripe-signature header, and your webhook secret. Without verification, anyone could send fake events to your endpoint.
Why it's a problem: Relying on the success URL redirect for order fulfillment
How to avoid: Customers can reach the success URL without paying. Use webhooks as the source of truth for fulfilling orders.
Why it's a problem: Not making the webhook handler idempotent
How to avoid: Stripe retries failed webhook deliveries. Track processed event IDs in your database to avoid duplicate fulfillment.
Best practices
- Always verify webhook signatures with stripe.webhooks.constructEvent() and the raw request body
- Register the webhook route before express.json() middleware to preserve the raw body
- Use webhooks as the source of truth for payment verification — not redirects or polling
- Make webhook handlers idempotent by tracking processed event IDs
- Return 200 quickly from webhook handlers — do heavy processing asynchronously
- Test locally with the Stripe CLI: stripe listen --forward-to localhost:4000/webhook
- Monitor webhook delivery in Dashboard → Developers → Webhooks → event logs
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Express webhook endpoint for Stripe that verifies the signature using stripe.webhooks.constructEvent() with the raw body, handles checkout.session.completed and payment_intent.succeeded events, and is idempotent. Register the route before express.json() middleware.
Add a Stripe webhook to my server. Create a POST /webhook endpoint that uses express.raw() for the body, verifies the stripe-signature header with constructEvent, and handles checkout.session.completed to fulfill orders. Make sure it's registered before express.json().
Frequently asked questions
Why can't I just check the PaymentIntent status from my success page?
Checking from the success page only works if the customer actually reaches that page. If they close the browser, lose connection, or the redirect fails, you never get the verification. Webhooks fire regardless of client-side behavior.
What happens if my webhook endpoint is down?
Stripe retries failed webhook deliveries for up to 3 days with exponential backoff. You can also manually replay events from the Dashboard → Developers → Webhooks → event logs.
Do I need both payment_intent.succeeded and checkout.session.completed?
If you use Checkout Sessions, listen for checkout.session.completed. If you use PaymentIntents directly, listen for payment_intent.succeeded. Listening to both is fine — just make sure your fulfillment logic is idempotent.
How do I test webhooks without deploying?
Use the Stripe CLI: run 'stripe listen --forward-to localhost:4000/webhook' to forward test events to your local server. Trigger events with 'stripe trigger payment_intent.succeeded'.
Can someone send fake webhook events to my endpoint?
Not if you verify the signature. stripe.webhooks.constructEvent() checks the stripe-signature header against your webhook secret. If the signature doesn't match, the event is rejected.
Can RapidDev help with webhook infrastructure for production?
Yes. RapidDev can set up production-grade webhook handling including signature verification, idempotent processing, dead-letter queues for failed events, and monitoring for your Stripe integration.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation