Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to validate Stripe payment success on backend

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.

What you'll learn

  • How to set up a Stripe webhook endpoint with signature verification
  • Why webhooks are more reliable than polling or redirect-based verification
  • How to handle checkout.session.completed and payment_intent.succeeded events
  • How to test webhooks locally with the Stripe CLI
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate6 min read20 minutesStripe API v2024-12+, Node.js 18+March 2026RapidDev Engineering Team
TL;DR

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

1

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().

typescript
1const express = require('express');
2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
3const app = express();
4
5// CRITICAL: Webhook route must use raw body
6app.post(
7 '/webhook',
8 express.raw({ type: 'application/json' }),
9 (req, res) => {
10 const sig = req.headers['stripe-signature'];
11 let event;
12
13 try {
14 event = stripe.webhooks.constructEvent(
15 req.body,
16 sig,
17 process.env.STRIPE_WEBHOOK_SECRET
18 );
19 } catch (err) {
20 console.error('Webhook signature verification failed:', err.message);
21 return res.status(400).send(`Webhook Error: ${err.message}`);
22 }
23
24 // Handle the event
25 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 }
35
36 res.json({ received: true });
37 }
38);
39
40// Regular routes use JSON parsing — AFTER webhook route
41app.use(express.json());

Expected result: The endpoint receives webhook events, verifies the signature, and processes them.

2

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.

typescript
1function handlePaymentSuccess(paymentIntent) {
2 console.log('Payment succeeded:', paymentIntent.id);
3 console.log('Amount:', paymentIntent.amount, paymentIntent.currency);
4 console.log('Metadata:', paymentIntent.metadata);
5
6 // TODO: Fulfill the order
7 // - Update order status in your database
8 // - Send confirmation email
9 // - Grant access to digital product
10 // - Trigger shipping for physical product
11}
12
13function 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);
17
18 if (session.payment_status === 'paid') {
19 // Fulfill the order
20 }
21}

Expected result: Payment details are logged and your fulfillment logic runs only for verified payments.

3

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.

typescript
1// Dashboard settings:
2// Endpoint URL: https://yoursite.com/webhook
3// Events to send:
4// - payment_intent.succeeded
5// - payment_intent.payment_failed
6// - checkout.session.completed
7// - checkout.session.expired
8
9// Copy the Signing secret (whsec_...) to your environment variables

Expected result: Stripe sends selected events to your endpoint. The signing secret is available for verification.

4

Test locally with the Stripe CLI

Install the Stripe CLI and forward webhook events to your local server for testing.

typescript
1# Install Stripe CLI (macOS)
2brew install stripe/stripe-cli/stripe
3
4# Login to your Stripe account
5stripe login
6
7# Forward events to your local server
8stripe listen --forward-to localhost:4000/webhook
9
10# The CLI prints a webhook signing secret (whsec_...).
11# Use THIS secret for local testing, not the Dashboard one.
12
13# In another terminal, trigger a test event:
14stripe trigger payment_intent.succeeded

Expected result: The CLI forwards events to localhost:4000/webhook and your handler processes them.

5

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.

typescript
1const processedEvents = new Set(); // Use a database in production
2
3app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
4 // ... signature verification ...
5
6 // Idempotency check
7 if (processedEvents.has(event.id)) {
8 return res.json({ received: true }); // Already processed
9 }
10 processedEvents.add(event.id);
11
12 // 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

server.js
1const express = require('express');
2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
3
4const app = express();
5
6// 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;
13
14 try {
15 event = stripe.webhooks.constructEvent(
16 req.body,
17 sig,
18 process.env.STRIPE_WEBHOOK_SECRET
19 );
20 } catch (err) {
21 console.error('Webhook signature failed:', err.message);
22 return res.status(400).send(`Webhook Error: ${err.message}`);
23 }
24
25 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 access
31 }
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 }
47
48 res.json({ received: true });
49 }
50);
51
52// Regular middleware — AFTER webhook route
53app.use(express.static('public'));
54app.use(express.json());
55
56// Checkout Session creation
57app.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});
73
74app.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.

ChatGPT Prompt

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.

Stripe Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.