SAAS

Stripe Webhooks Tutorial: Node.js

Here is how most developers first encounter Stripe webhooks: they ship a checkout flow, a payment succeeds in testing, and then they realize their app has no idea the payment happened. The Stripe Dashboard shows the charge. Their database shows nothing. They had been relying on a redirect URL to fulfill orders, and when a customer closed the tab before the redirect completed, the order silently vanished.

Stripe Webhooks Tutorial: Node.js

Webhooks fix this. Instead of your app waiting for a user to return from checkout, Stripe pushes a notification to your server the moment anything meaningful happens: a payment succeeds, a subscription renews, a charge is refunded, a customer disputes a payment. Your server receives the event, processes it, and responds. The customer’s browser is not involved.

How Stripe Webhooks Work

When a billable event occurs in Stripe, Stripe creates an Event object and sends an HTTP POST request containing that object as JSON to each webhook endpoint URL you have registered in your Dashboard. Your server must return a 200 status code within 30 seconds. If it does not, Stripe marks the delivery as failed and retries with exponential backoff over the next 72 hours.

The retry behavior is important to understand before writing your first handler. Stripe will attempt delivery up to 35 times over three days for a failed endpoint. This means your handler will receive the same event multiple times if your server was down or returned an error on the first attempt. Any processing logic that is not idempotent, meaning it produces different results when run twice on the same input, will cause duplicate actions: double-fulfilled orders, double-credited accounts, double-sent confirmation emails.

Idempotency is not optional. It is the foundational requirement of webhook handling. Every action your handler takes in response to a webhook must check whether that action has already been taken for this specific event before executing it.

Building Your First Webhook Endpoint

Install Dependencies and Create the Endpoint

You need the stripe npm package and a way to read the raw request body before any JSON parsing middleware touches it. Express’s built-in JSON parser processes the body before your route handler sees it, which breaks Stripe’s signature verification. Use express.raw() for the webhook route specifically:

npm install stripe express

const express = require('express');

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

const app = express();

// CRITICAL: use raw body parser for webhook route only

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 failed:', err.message);

   return res.status(400).send('Webhook Error');

}

// Handle the event

res.json({ received: true });

  }

);

Signature Verification: Why It Matters

The stripe.webhooks.constructEvent() call does two things: it parses the JSON body and it verifies that the request genuinely came from Stripe by checking the HMAC-SHA256 signature in the Stripe-Signature header against your webhook signing secret. Skip this verification and any attacker who knows your endpoint URL can send fake payment success events to your server.

Your webhook signing secret is different from your Stripe API key. Find it in the Stripe Dashboard under Developers > Webhooks after creating an endpoint, or in the CLI output when running stripe listen. Use environment variables for both secrets and never commit either to version control.

Testing Locally With the Stripe CLI

Stripe webhooks require a publicly reachable URL, which your local development server is not. The Stripe CLI solves this by creating a secure tunnel that forwards webhook events from Stripe to your local port:

# Install the Stripe CLI, then:

stripe login

stripe listen --forward-to localhost:3000/webhook

The listen command outputs a webhook signing secret that starts with whsec_. Use this as your STRIPE_WEBHOOK_SECRET environment variable during development. It is different from your production webhook secret and only valid for the duration of the CLI session.

To trigger specific events without making real transactions, use stripe trigger in a separate terminal:

stripe trigger payment_intent.succeeded

stripe trigger customer.subscription.deleted

Triggering events this way lets you test every handler branch, including cancellation and failure flows, without needing real payment methods or waiting for actual customer actions.

The Stripe Events That Matter Most 

EventWhen It FiresTypical Action
payment_intent.succeededPayment fully confirmedFulfill order, provision access
payment_intent.payment_failedPayment attempt failedNotify customer, prompt retry
invoice.payment_succeededSubscription invoice paidExtend subscription, send receipt
invoice.payment_failedSubscription payment failedBegin dunning, restrict access
customer.subscription.updatedPlan change, trial end, quantity changeSync plan to your DB
customer.subscription.deletedSubscription cancelled or expiredRevoke access, offboard flow
charge.dispute.createdCustomer initiated chargebackFlag account, gather evidence
checkout.session.completedStripe Checkout session paidFulfill, provision, redirect

Event Handling Patterns for Production

The Switch Pattern and Unhandled Events

Structure your event handling as a switch on event.type. Always include a default case that logs unhandled event types rather than silently discarding them. Stripe adds new event types regularly, and a default log gives you visibility when new events start arriving at your endpoint:

switch (event.type) {

  case 'payment_intent.succeeded':

await handlePaymentSuccess(event.data.object);

break;

  case 'customer.subscription.deleted':

await handleSubscriptionCancelled(event.data.object);

break;

  default:

console.log('Unhandled event type:', event.type);

}

Idempotency Implementation

Store processed event IDs in your database and check before acting. A simple implementation with a processed_events table:

async function handlePaymentSuccess(paymentIntent) {

  const alreadyProcessed = await db.query(

'SELECT id FROM processed_events WHERE stripe_event_id = $1',

[paymentIntent.id]

  );

  if (alreadyProcessed.rows.length > 0) return;

  // Process the payment

  await fulfillOrder(paymentIntent.metadata.order_id);

  // Record that we processed this event

  await db.query(

'INSERT INTO processed_events (stripe_event_id) VALUES ($1)',

[paymentIntent.id]

  );

}

Responding Fast and Processing Asynchronously

Stripe requires a 200 response within 30 seconds. Most webhook processing, including database writes, email sends, and downstream API calls, should complete well within that window. But if your handler calls a slow external service or processes high volumes of events in bursts, you risk hitting the timeout and triggering unnecessary retries.

The robust pattern is to acknowledge the webhook immediately by returning 200, then process the event asynchronously through a job queue. Libraries like Bull (Redis-backed), BullMQ, or a managed queue service like AWS SQS let you push the event payload onto a queue, return 200 to Stripe instantly, and process the event in a background worker:

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

  // Verify signature first

  let event;

  try {

event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);

  } catch (err) {

return res.status(400).send('Signature error');

  }

  // Push to queue and return immediately

  await eventQueue.add('stripe-event', event);

  res.json({ received: true });

});

Your worker then processes the event from the queue with full retry logic, dead-letter handling, and observability. This pattern also makes it straightforward to replay failed events by re-queuing them without needing Stripe to re-deliver.

The Most Common Webhook Mistakes

  • Parsing the body before signature verification: Express’s json() middleware consumes the raw body. Use express.raw() on the webhook route before any global middleware runs. Register the webhook route before app.use(express.json()).
  • Returning 200 before processing is complete: If your handler throws after sending the response, the event is marked delivered but your processing failed silently. Either use the queue pattern above or ensure all processing happens before the response.
  • Using the wrong webhook secret: The Stripe CLI’s whsec_ secret is only valid for local testing. Production endpoints have their own signing secrets in the Dashboard. Using the wrong one causes every production webhook to fail signature verification with no clear error message.
  • Not handling subscription events comprehensively: Many developers handle payment_intent.succeeded but skip invoice.payment_failed. A customer whose subscription renewal fails stays provisioned indefinitely until someone notices. Subscription lifecycle management requires handling the full set of invoice and subscription events.
  • Fetching the Stripe object instead of using event data: The event payload contains the full object state at the time the event fired. Fetching it fresh from the Stripe API introduces a race condition where the object may have changed between when the event fired and when your fetch completes. Use event.data.object directly.

Frequently Asked Questions

How do I register a webhook endpoint in the Stripe Dashboard?

Go to Developers > Webhooks in your Stripe Dashboard and click Add endpoint. Enter your publicly reachable URL (e.g., https://yourapp.com/webhook), select the specific events you want to receive or choose Receive all events, and save. Stripe will display the signing secret for that endpoint. In production, register separate endpoints for live mode and test mode. 

Can I receive the same event on multiple endpoints?

Yes. You can register multiple endpoints in the Stripe Dashboard and all of them will receive every event type they are subscribed to. This is useful for sending payment events to both your application server and a separate analytics or data warehouse pipeline. 

What should I do if my endpoint was down and I missed events?

Stripe retries failed deliveries for 72 hours, so recent events will be re-delivered automatically once your endpoint is healthy and returning 200. For events older than 72 hours, use the Stripe Dashboard’s Events section to manually resend individual events, or use the Stripe API’s event list endpoint to fetch events by type and time range and replay them through your processing logic directly.

Why does Stripe send events in a different order than I expected?

Stripe does not guarantee event delivery order. A payment_intent.succeeded event may arrive before or after the checkout.session.completed event for the same transaction. Design your handlers to be order-independent. Never rely on one event having already been processed when a second event arrives. 

Conclusion

Every SaaS or e-commerce application built on Stripe eventually learns the same lesson: client-side redirects and polling are not a substitute for server-side event handling. Tabs get closed. Connections drop. Race conditions surface in ways that are nearly impossible to reproduce. Webhooks remove the user’s browser from the critical path for order fulfillment, subscription management, and payment reconciliation entirely.

Build the idempotency check first, before anything else in your handler. Verify signatures on every request without exception. Test every event type you handle with stripe trigger before shipping. And when your endpoint inevitably goes down, the retry mechanism has you covered as long as you come back online within 72 hours. The infrastructure is reliable. The implementation is on you.

Leave a Reply

Your email address will not be published. Required fields are marked *