# How to validate Stripe payment success on backend

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 20 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+
- Last updated: March 2026

## 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.

## Before you start

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

```
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();

// CRITICAL: Webhook route must use raw body
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (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}`);
    }

    // Handle the event
    switch (event.type) {
      case 'payment_intent.succeeded':
        handlePaymentSuccess(event.data.object);
        break;
      case 'checkout.session.completed':
        handleCheckoutComplete(event.data.object);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    res.json({ received: true });
  }
);

// Regular routes use JSON parsing — AFTER webhook route
app.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.

```
function handlePaymentSuccess(paymentIntent) {
  console.log('Payment succeeded:', paymentIntent.id);
  console.log('Amount:', paymentIntent.amount, paymentIntent.currency);
  console.log('Metadata:', paymentIntent.metadata);

  // TODO: Fulfill the order
  // - Update order status in your database
  // - Send confirmation email
  // - Grant access to digital product
  // - Trigger shipping for physical product
}

function handleCheckoutComplete(session) {
  console.log('Checkout completed:', session.id);
  console.log('Payment status:', session.payment_status);
  console.log('Customer email:', session.customer_details?.email);

  if (session.payment_status === 'paid') {
    // Fulfill the order
  }
}
```

**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.

```
// Dashboard settings:
// Endpoint URL: https://yoursite.com/webhook
// Events to send:
//   - payment_intent.succeeded
//   - payment_intent.payment_failed
//   - checkout.session.completed
//   - checkout.session.expired

// 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.

```
# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Login to your Stripe account
stripe login

# Forward events to your local server
stripe listen --forward-to localhost:4000/webhook

# The CLI prints a webhook signing secret (whsec_...).
# Use THIS secret for local testing, not the Dashboard one.

# In another terminal, trigger a test event:
stripe 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.

```
const processedEvents = new Set(); // Use a database in production

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  // ... signature verification ...

  // Idempotency check
  if (processedEvents.has(event.id)) {
    return res.json({ received: true }); // Already processed
  }
  processedEvents.add(event.id);

  // Process the event...
  res.json({ received: true });
});
```

**Expected result:** Duplicate webhook deliveries are safely ignored without re-processing the order.

## Complete code example

File: `server.js`

```javascript
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

// Webhook endpoint — MUST be before express.json()
app.post(
  '/webhook',
  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 failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object;
        if (session.payment_status === 'paid') {
          console.log('Order fulfilled for session:', session.id);
          // TODO: update database, send email, grant access
        }
        break;
      }
      case 'payment_intent.succeeded': {
        const pi = event.data.object;
        console.log('Payment confirmed:', pi.id, pi.amount, pi.currency);
        break;
      }
      case 'payment_intent.payment_failed': {
        const pi = event.data.object;
        console.log('Payment failed:', pi.id, pi.last_payment_error?.message);
        break;
      }
      default:
        console.log(`Unhandled event: ${event.type}`);
    }

    res.json({ received: true });
  }
);

// Regular middleware — AFTER webhook route
app.use(express.static('public'));
app.use(express.json());

// Checkout Session creation
app.post('/create-checkout-session', async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: 'Widget' },
        unit_amount: 2000,
      },
      quantity: 1,
    }],
    success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.headers.origin}/cancel`,
  });
  res.json({ url: session.url });
});

app.listen(4000, () => console.log('Server on port 4000'));
```

## Common mistakes

- **Using express.json() before the webhook route** — undefined Fix: express.json() parses the body, but constructEvent requires the raw Buffer. Register the webhook route with express.raw() BEFORE any express.json() middleware.
- **Not verifying the webhook signature** — undefined Fix: 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.
- **Relying on the success URL redirect for order fulfillment** — undefined Fix: Customers can reach the success URL without paying. Use webhooks as the source of truth for fulfilling orders.
- **Not making the webhook handler idempotent** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-validate-stripe-payment-success-on-backend
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-validate-stripe-payment-success-on-backend
