Skip to content
Payments45-60 min

Stripe Webhooks Implementation

This guide explains raw body handling, signature verification, idempotency, event routing, local Stripe CLI testing, and production webhook monitoring.

StripeNode.jsExpress

Prerequisites

  • A Stripe account with test mode access.
  • An Express API or serverless endpoint.
  • A database table for payments, subscriptions, or processed webhook event IDs.
  • Stripe CLI installed for local testing.
1

Install Stripe

Implementation snippet
npm install stripe
2

Use raw body for the webhook route

Stripe signature verification needs the exact raw request body. Apply JSON parsing after the webhook route or exclude this route from JSON parsing.

Implementation snippet
app.post("/api/stripe/webhook", express.raw({ type: "application/json" }), stripeWebhookHandler);
app.use(express.json());
Warning: If `express.json()` runs before the webhook route, signature verification can fail even when the secret is correct.
3

Verify the signature

Implementation snippet
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function stripeWebhookHandler(req, res) {
  const signature = req.headers["stripe-signature"];

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (error) {
    return res.status(400).send(`Webhook signature failed: ${error.message}`);
  }

  await handleStripeEvent(event);
  res.json({ received: true });
}
4

Route important events

  1. Use `checkout.session.completed` to activate purchases or start onboarding.
  2. Use `customer.subscription.updated` to sync plan, status, trial, and renewal date.
  3. Use `customer.subscription.deleted` to remove paid access.
  4. Use `invoice.payment_failed` to mark accounts as past_due and notify users.
Implementation snippet
async function handleStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed":
      return syncCheckoutSession(event.data.object as Stripe.Checkout.Session);
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      return syncSubscription(event.data.object as Stripe.Subscription);
    case "invoice.payment_failed":
      return markPaymentFailed(event.data.object as Stripe.Invoice);
    default:
      return;
  }
}
5

Make webhook handling idempotent

  1. Create a `stripe_webhook_events` table with a unique `event_id` column.
  2. Insert the event ID before processing business changes.
  3. If the insert fails because the event already exists, return success without reprocessing.
  4. Wrap event recording and business updates in a transaction when possible.
Note: Stripe may retry events. Idempotency prevents duplicate invoices, duplicate emails, and repeated entitlement changes.
6

Test locally with Stripe CLI

Implementation snippet
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
7

Production checklist

Checklist
  • Create a live webhook endpoint in Stripe Dashboard.
  • Store the live webhook signing secret separately from the test secret.
  • Monitor failed webhook deliveries for the first release week.
  • Log event ID, event type, customer ID, and processing result.
  • Return a 2xx response only after the event is safely processed or safely ignored.

Need implementation help?

Want this built correctly in your codebase?

Send us your stack, repo context, and the feature you need. We will help you implement it cleanly and hand over the working code.

Free scoping callFixed timelineFull source ownership
Get implementation help