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

How to handle webhooks in Stripe

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.

What you'll learn

  • How to create a webhook endpoint and register it with Stripe
  • How to verify webhook signatures to prevent spoofing
  • Which events to listen for and how to handle them
  • How to implement idempotent event processing and error handling
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate7 min read20 minutesStripe API v2024-12+, Node.js 18+, Express.jsMarch 2026RapidDev Engineering Team
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.

Prerequisites

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

typescript
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2const express = require('express');
3const app = express();
4
5// CRITICAL: webhook route must use express.raw(), not express.json()
6app.post('/webhook',
7 express.raw({ type: 'application/json' }),
8 (req, res) => {
9 const sig = req.headers['stripe-signature'];
10 let event;
11
12 try {
13 event = stripe.webhooks.constructEvent(
14 req.body, // Raw body buffer
15 sig, // Stripe-Signature header
16 process.env.STRIPE_WEBHOOK_SECRET // whsec_ from Dashboard
17 );
18 } catch (err) {
19 console.error('Webhook signature verification failed:', err.message);
20 return res.status(400).send(`Webhook Error: ${err.message}`);
21 }
22
23 console.log('Verified event:', event.type, event.id);
24 res.json({ received: true });
25 }
26);
27
28// Other routes can use express.json() normally
29app.use(express.json());
30
31app.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.

typescript
1function handleEvent(event) {
2 switch (event.type) {
3 // Payment events
4 case 'payment_intent.succeeded': {
5 const pi = event.data.object;
6 console.log(`Payment ${pi.id}: $${pi.amount / 100} ${pi.currency}`);
7 // Fulfill the order
8 break;
9 }
10 case 'payment_intent.payment_failed': {
11 const pi = event.data.object;
12 console.log(`Payment failed: ${pi.id}`, pi.last_payment_error?.message);
13 // Notify the customer
14 break;
15 }
16
17 // Subscription events
18 case 'customer.subscription.created':
19 console.log('New subscription:', event.data.object.id);
20 break;
21 case 'customer.subscription.deleted':
22 console.log('Subscription canceled:', event.data.object.id);
23 // Revoke access
24 break;
25 case 'invoice.payment_failed':
26 console.log('Invoice payment failed:', event.data.object.id);
27 // Send dunning email
28 break;
29
30 // Dispute events
31 case 'charge.dispute.created':
32 console.log('Dispute opened:', event.data.object.id);
33 // Alert your team
34 break;
35
36 // Connect events
37 case 'account.updated': {
38 const acct = event.data.object;
39 console.log(`Account ${acct.id}: charges=${acct.charges_enabled}`);
40 break;
41 }
42
43 default:
44 console.log(`Unhandled event type: ${event.type}`);
45 }
46}

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.

typescript
1// Simple in-memory set for deduplication (use Redis or DB in production)
2const processedEvents = new Set();
3
4function processEventIdempotently(event) {
5 // Check if we have already processed this event
6 if (processedEvents.has(event.id)) {
7 console.log(`Event ${event.id} already processed, skipping`);
8 return;
9 }
10
11 // Process the event
12 handleEvent(event);
13
14 // Mark as processed
15 processedEvents.add(event.id);
16
17 // Clean up old events (keep last 10,000)
18 if (processedEvents.size > 10000) {
19 const iterator = processedEvents.values();
20 processedEvents.delete(iterator.next().value);
21 }
22}
23
24// In production, use a database:
25// INSERT INTO processed_events (event_id, processed_at)
26// VALUES ($1, NOW())
27// ON CONFLICT (event_id) DO NOTHING
28// 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.

typescript
1// After registering in the Dashboard, you receive a signing secret:
2// whsec_abc123...
3//
4// Store it as an environment variable:
5// STRIPE_WEBHOOK_SECRET=whsec_abc123...
6//
7// Common events to subscribe to:
8// - payment_intent.succeeded
9// - payment_intent.payment_failed
10// - customer.subscription.created
11// - customer.subscription.updated
12// - customer.subscription.deleted
13// - invoice.payment_failed
14// - invoice.paid
15// - charge.dispute.created
16// - charge.refunded
17// - account.updated (for Connect)
18// - checkout.session.completed
19
20console.log('Register your endpoint at:');
21console.log('Dashboard → Developers → Webhooks → Add endpoint');
22console.log('URL: https://yoursite.com/webhook');
23console.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.

typescript
1app.post('/webhook',
2 express.raw({ type: 'application/json' }),
3 async (req, res) => {
4 const sig = req.headers['stripe-signature'];
5 let event;
6
7 try {
8 event = stripe.webhooks.constructEvent(
9 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
10 );
11 } catch (err) {
12 return res.status(400).send(`Webhook Error: ${err.message}`);
13 }
14
15 // Respond immediately — Stripe retries if no 2xx within 20 seconds
16 res.json({ received: true });
17
18 // Process the event asynchronously
19 try {
20 await processEventAsync(event);
21 } catch (err) {
22 console.error(`Error processing ${event.type}:`, err.message);
23 // Log to error tracking (Sentry, etc.)
24 // The event will be retried by Stripe if needed
25 }
26 }
27);
28
29async function processEventAsync(event) {
30 // Your business logic here
31 // Can take as long as needed since we already responded 200
32 handleEvent(event);
33}

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

Complete working example

stripe-webhook-server.js
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2const express = require('express');
3const app = express();
4
5const processedEvents = new Set();
6
7// Webhook endpoint — MUST use raw body
8app.post('/webhook',
9 express.raw({ type: 'application/json' }),
10 (req, res) => {
11 const sig = req.headers['stripe-signature'];
12 let event;
13 try {
14 event = stripe.webhooks.constructEvent(
15 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
16 );
17 } catch (err) {
18 console.error('Signature verification failed:', err.message);
19 return res.status(400).send(`Webhook Error: ${err.message}`);
20 }
21
22 // Respond immediately
23 res.json({ received: true });
24
25 // Idempotency check
26 if (processedEvents.has(event.id)) return;
27 processedEvents.add(event.id);
28
29 // Route events
30 switch (event.type) {
31 case 'payment_intent.succeeded': {
32 const pi = event.data.object;
33 console.log(`Payment succeeded: ${pi.id} ($${pi.amount / 100})`);
34 break;
35 }
36 case 'payment_intent.payment_failed': {
37 const pi = event.data.object;
38 console.log(`Payment failed: ${pi.id}`);
39 break;
40 }
41 case 'customer.subscription.deleted': {
42 console.log('Sub canceled:', event.data.object.id);
43 break;
44 }
45 case 'invoice.payment_failed': {
46 console.log('Invoice failed:', event.data.object.id);
47 break;
48 }
49 case 'charge.dispute.created': {
50 console.log('Dispute:', event.data.object.id);
51 break;
52 }
53 case 'account.updated': {
54 const acct = event.data.object;
55 console.log(`Account ${acct.id} updated`);
56 break;
57 }
58 default:
59 console.log(`Unhandled: ${event.type}`);
60 }
61 }
62);
63
64// Non-webhook routes use JSON parsing
65app.use(express.json());
66
67app.get('/health', (req, res) => res.json({ status: 'ok' }));
68
69app.listen(3000, () => console.log('Webhook server on port 3000'));

Common mistakes when handling webhooks in Stripe

Why it's a problem: Using express.json() on the webhook route which parses the raw body

How to avoid: Use express.raw({ type: 'application/json' }) for the webhook route. Place it BEFORE any global 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, signature header, and webhook secret. Never skip verification.

Why it's a problem: Taking too long to respond, causing Stripe to retry

How to avoid: Return res.json({ received: true }) immediately, then process the event asynchronously.

Why it's a problem: Not handling duplicate events (retries)

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

How do I set up Stripe webhooks in Node.js? I need signature verification with constructEvent, handling for payment and subscription events, and idempotent processing. Show me the complete Express.js setup.

Stripe Prompt

Build a production-ready Stripe webhook handler for my Node.js Express app. I need signature verification with raw body parsing, handlers for payment_intent.succeeded, subscription events, and dispute events, plus idempotent processing with event deduplication.

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.

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.