# How to handle webhooks in Stripe

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

## TL;DR

Stripe webhooks deliver real-time event notifications to your server when things happen in your Stripe account — payments succeed, subscriptions renew, disputes are created. This guide covers setting up a webhook endpoint, verifying signatures with stripe.webhooks.constructEvent and raw body parsing, handling key events, and implementing retry logic for reliable event processing.

## Complete Guide to Stripe Webhook Integration

Webhooks are the backbone of reliable Stripe integrations. Instead of polling the API, Stripe pushes event notifications to your server in real time. Critical flows like order fulfillment, subscription management, and fraud alerts depend on webhooks. The most important part is signature verification — every webhook request includes a Stripe-Signature header that you must validate using your webhook endpoint secret and the raw request body.

## Before you start

- Stripe account (test mode)
- Node.js 18 or later installed
- Stripe Node.js SDK: npm install stripe
- Express.js: npm install express
- A publicly accessible URL (or Stripe CLI for local testing)

## Step-by-step guide

### 1. Create the webhook endpoint with signature verification

The critical detail: you must pass the raw request body (not parsed JSON) to constructEvent. Use express.raw() middleware on the webhook route.

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

// CRITICAL: webhook route must use express.raw(), not express.json()
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,              // Raw body buffer
        sig,                   // Stripe-Signature header
        process.env.STRIPE_WEBHOOK_SECRET  // whsec_ from Dashboard
      );
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    console.log('Verified event:', event.type, event.id);
    res.json({ received: true });
  }
);

// Other routes can use express.json() normally
app.use(express.json());

app.listen(3000, () => console.log('Webhook server on port 3000'));
```

**Expected result:** Webhook requests are verified against Stripe's signature. Invalid or spoofed requests are rejected with a 400 status.

### 2. Handle key Stripe events

Route different event types to appropriate handlers. Focus on the events that matter for your business logic.

```
function handleEvent(event) {
  switch (event.type) {
    // Payment events
    case 'payment_intent.succeeded': {
      const pi = event.data.object;
      console.log(`Payment ${pi.id}: $${pi.amount / 100} ${pi.currency}`);
      // Fulfill the order
      break;
    }
    case 'payment_intent.payment_failed': {
      const pi = event.data.object;
      console.log(`Payment failed: ${pi.id}`, pi.last_payment_error?.message);
      // Notify the customer
      break;
    }

    // Subscription events
    case 'customer.subscription.created':
      console.log('New subscription:', event.data.object.id);
      break;
    case 'customer.subscription.deleted':
      console.log('Subscription canceled:', event.data.object.id);
      // Revoke access
      break;
    case 'invoice.payment_failed':
      console.log('Invoice payment failed:', event.data.object.id);
      // Send dunning email
      break;

    // Dispute events
    case 'charge.dispute.created':
      console.log('Dispute opened:', event.data.object.id);
      // Alert your team
      break;

    // Connect events
    case 'account.updated': {
      const acct = event.data.object;
      console.log(`Account ${acct.id}: charges=${acct.charges_enabled}`);
      break;
    }

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
}
```

**Expected result:** Each event type is routed to the appropriate handler for business logic processing.

### 3. Implement idempotent event processing

Stripe may send the same event multiple times (retries). Use the event ID to ensure you process each event only once.

```
// Simple in-memory set for deduplication (use Redis or DB in production)
const processedEvents = new Set();

function processEventIdempotently(event) {
  // Check if we have already processed this event
  if (processedEvents.has(event.id)) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  // Process the event
  handleEvent(event);

  // Mark as processed
  processedEvents.add(event.id);

  // Clean up old events (keep last 10,000)
  if (processedEvents.size > 10000) {
    const iterator = processedEvents.values();
    processedEvents.delete(iterator.next().value);
  }
}

// In production, use a database:
// INSERT INTO processed_events (event_id, processed_at)
// VALUES ($1, NOW())
// ON CONFLICT (event_id) DO NOTHING
// RETURNING event_id
```

**Expected result:** Duplicate webhook events are detected and skipped, preventing double-processing.

### 4. Register the webhook endpoint in Stripe Dashboard

Go to Dashboard → Developers → Webhooks → Add endpoint. Enter your URL and select which events to receive. Copy the signing secret (whsec_) to your environment variables.

```
// After registering in the Dashboard, you receive a signing secret:
// whsec_abc123...
//
// Store it as an environment variable:
// STRIPE_WEBHOOK_SECRET=whsec_abc123...
//
// Common events to subscribe to:
// - payment_intent.succeeded
// - payment_intent.payment_failed
// - customer.subscription.created
// - customer.subscription.updated
// - customer.subscription.deleted
// - invoice.payment_failed
// - invoice.paid
// - charge.dispute.created
// - charge.refunded
// - account.updated (for Connect)
// - checkout.session.completed

console.log('Register your endpoint at:');
console.log('Dashboard → Developers → Webhooks → Add endpoint');
console.log('URL: https://yoursite.com/webhook');
console.log('Copy the signing secret to STRIPE_WEBHOOK_SECRET env var');
```

**Expected result:** Webhook endpoint is registered in Stripe and will receive events for the selected types.

### 5. Handle errors and respond quickly

Stripe expects a 2xx response within 20 seconds. If your handler takes longer, return 200 immediately and process the event asynchronously.

```
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) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Respond immediately — Stripe retries if no 2xx within 20 seconds
    res.json({ received: true });

    // Process the event asynchronously
    try {
      await processEventAsync(event);
    } catch (err) {
      console.error(`Error processing ${event.type}:`, err.message);
      // Log to error tracking (Sentry, etc.)
      // The event will be retried by Stripe if needed
    }
  }
);

async function processEventAsync(event) {
  // Your business logic here
  // Can take as long as needed since we already responded 200
  handleEvent(event);
}
```

**Expected result:** Webhook responds immediately with 200, then processes the event asynchronously without risking Stripe timeouts.

## Complete code example

File: `stripe-webhook-server.js`

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

const processedEvents = new Set();

// Webhook endpoint — 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('Signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Respond immediately
    res.json({ received: true });

    // Idempotency check
    if (processedEvents.has(event.id)) return;
    processedEvents.add(event.id);

    // Route events
    switch (event.type) {
      case 'payment_intent.succeeded': {
        const pi = event.data.object;
        console.log(`Payment succeeded: ${pi.id} ($${pi.amount / 100})`);
        break;
      }
      case 'payment_intent.payment_failed': {
        const pi = event.data.object;
        console.log(`Payment failed: ${pi.id}`);
        break;
      }
      case 'customer.subscription.deleted': {
        console.log('Sub canceled:', event.data.object.id);
        break;
      }
      case 'invoice.payment_failed': {
        console.log('Invoice failed:', event.data.object.id);
        break;
      }
      case 'charge.dispute.created': {
        console.log('Dispute:', event.data.object.id);
        break;
      }
      case 'account.updated': {
        const acct = event.data.object;
        console.log(`Account ${acct.id} updated`);
        break;
      }
      default:
        console.log(`Unhandled: ${event.type}`);
    }
  }
);

// Non-webhook routes use JSON parsing
app.use(express.json());

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.listen(3000, () => console.log('Webhook server on port 3000'));
```

## Common mistakes

- **Using express.json() on the webhook route which parses the raw body** — undefined Fix: Use express.raw({ type: 'application/json' }) for the webhook route. Place it BEFORE any global express.json() middleware.
- **Not verifying the webhook signature** — undefined Fix: Always call stripe.webhooks.constructEvent with the raw body, signature header, and webhook secret. Never skip verification.
- **Taking too long to respond, causing Stripe to retry** — undefined Fix: Return res.json({ received: true }) immediately, then process the event asynchronously.
- **Not handling duplicate events (retries)** — undefined Fix: Store processed event IDs and skip duplicates. Use a database with UNIQUE constraint on event_id in production.

## Best practices

- Always verify webhook signatures with stripe.webhooks.constructEvent and raw body
- Respond with 200 within 20 seconds — process events asynchronously if needed
- Implement idempotent event processing to handle retries safely
- Subscribe only to the events you need to reduce noise and processing load
- Log all received events for debugging and auditing
- Use the Stripe CLI (stripe listen) for local development testing
- Set up monitoring alerts if your webhook endpoint starts returning errors
- For production webhook infrastructure, RapidDev can help build reliable event processing pipelines

## Frequently asked questions

### What happens if my webhook endpoint is down?

Stripe retries failed webhook deliveries over 3 days with exponential backoff. After all retries are exhausted, the event is marked as failed. You can view missed events in Dashboard → Developers → Webhooks.

### Can I receive webhooks for events that happened before I registered the endpoint?

No. Webhooks only deliver events that occur after the endpoint is registered. For historical events, use the Events API: stripe.events.list().

### Why does signature verification fail?

The most common cause is parsing the request body as JSON before passing it to constructEvent. The raw body bytes must match exactly. Use express.raw() on the webhook route.

### How many events can Stripe send per second?

Stripe can send hundreds of events per second during high traffic. Ensure your endpoint can handle the load. Use a message queue (SQS, Redis) for heavy processing.

### Can I have multiple webhook endpoints?

Yes. You can create multiple endpoints, each subscribed to different events. This is useful for routing payment events to one service and subscription events to another.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-handle-webhooks-in-stripe-api
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-handle-webhooks-in-stripe-api
