All posts
stripewebhookspaymentsguide

Stripe Webhooks: A Practical Guide to Getting Them Right

Everything you need to know about receiving, verifying, and reliably handling Stripe webhook events. From setup through to production best practices.

26 May 20266 min read

Stripe's webhook system is one of the most well-designed in the industry. It is also the source of a surprising number of production bugs.

This is a practical guide to setting up Stripe webhooks correctly. Not just the happy path, but the things that bite you in production.

How Stripe webhooks work

When something happens in Stripe (a payment succeeds, a subscription renews, a customer disputes a charge) Stripe sends an HTTP POST to a URL you configure. The request body is a JSON object describing the event, signed with an HMAC-SHA256 signature using your webhook signing secret.

Stripe delivers webhooks with a 30-second timeout. If your endpoint does not return a 2xx within that window, Stripe considers it a failure and retries. The retry schedule starts at a few seconds and backs off over 72 hours, with up to 25 attempts total.

Setting up your endpoint

Create a dedicated route

Your webhook handler should be its own route, not part of a general API endpoint. It needs special treatment (raw body access for signature verification, no auth middleware) that will conflict with how you handle other requests.

// Express example
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }), // raw body required for sig verification
  handleStripeWebhook
);

Do not use express.json() globally if you are also handling Stripe webhooks. It consumes the raw body before your verification code can read it, causing every signature check to fail.

The critical part: use express.raw (or equivalent) on this route specifically. If you use express.json() globally and your webhook route parses the body as JSON first, the raw byte sequence Stripe signed is gone, and signature verification will always fail.

Verify every request

Never process a webhook without verifying its signature. Without verification, anyone who knows your endpoint URL can send fake events. If you want the full background, see webhook signature verification.

import Stripe from 'stripe';

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

async function handleStripeWebhook(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'];

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body, // raw Buffer, not parsed JSON
      sig!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return res.status(400).send('Webhook Error');
  }

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

  // Process asynchronously
  await processStripeEvent(event);
}

Return the 200 response before processing the event. Stripe waits for your response, so if your processing takes longer than 30 seconds, the delivery will time out even though you received it.

Retrieve events from Stripe, do not just trust the payload

For high-value events (payment succeeded, subscription created) fetch the object from Stripe's API rather than trusting the webhook payload directly.

async function processStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case 'payment_intent.succeeded': {
      // Fetch fresh from API rather than using event.data.object directly
      const paymentIntent = await stripe.paymentIntents.retrieve(
        (event.data.object as Stripe.PaymentIntent).id
      );
      await handlePaymentSucceeded(paymentIntent);
      break;
    }
  }
}

This protects against a replay attack where an old (valid) event is re-sent to trigger a side effect.

The events you actually need

Stripe sends dozens of event types. In practice, most applications only need a handful.

For one-time payments

EventWhen to use it
payment_intent.succeededPayment confirmed, safe to fulfil the order
payment_intent.payment_failedPayment declined, notify the customer
charge.dispute.createdCustomer disputed a charge, you need to respond

For subscriptions

EventWhen to use it
customer.subscription.createdNew subscription - provision access
customer.subscription.updatedPlan change, quantity update, or trial ending
customer.subscription.deletedSubscription cancelled or expired - revoke access
invoice.payment_succeededMonthly renewal confirmed
invoice.payment_failedRenewal failed - notify customer, retry logic

Only listening for customer.subscription.deleted is a common mistake. Subscriptions lapse via past_due then unpaid then deleted. If you only handle the final deletion, users can stay active long after payment fails.

The most common mistake with subscription webhooks: only handling customer.subscription.deleted for cancellations. If a subscription lapses due to failed payment, Stripe marks it as unpaid or past_due first. You need to handle those states too, not just outright cancellations.

For the Stripe billing portal

If you use Stripe's customer portal for subscription management:

  • customer.subscription.updated fires when a customer downgrades, upgrades, or cancels at period end
  • customer.subscription.deleted fires when the cancellation takes effect

The cancelAtPeriodEnd field on the subscription object tells you whether the customer cancelled but still has time remaining.

Idempotency: handling duplicate events

Stripe may deliver the same event more than once. If a network issue prevents them from receiving your 2xx, they will retry, even if you processed the event successfully the first time. This is a general webhook problem, not just a Stripe one, and we go deeper in stop processing the same webhook twice.

Your handlers must be idempotent: processing the same event twice should produce the same result as processing it once.

async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
  // Check if we've already processed this payment
  const existing = await db.orders.findOne({
    stripePaymentIntentId: paymentIntent.id,
  });

  if (existing?.status === 'fulfilled') {
    console.log(`Already processed payment ${paymentIntent.id}, skipping`);
    return;
  }

  // Process...
  await db.orders.update({
    where: { stripePaymentIntentId: paymentIntent.id },
    data: { status: 'fulfilled', fulfilledAt: new Date() },
  });
}

The simplest approach: store the Stripe event ID and skip processing if you have seen it before.

Testing locally

Use the Stripe CLI to forward events to your local server:

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

This also prints a signing secret (whsec_...) for local testing, different from your live webhook secret. Set it as STRIPE_WEBHOOK_SECRET in your local environment.

To trigger specific events:

stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted

Common mistakes and how to avoid them

"Stripe says it was delivered but we did not receive it"

Almost always a middleware issue. Something is consuming the request body before your webhook route gets the raw bytes. Check for:

  • Global express.json() middleware applied before your webhook route
  • Body parsing middleware in Next.js API routes (use export const config = { api: { bodyParser: false } })
  • A CDN or proxy stripping or modifying headers

"Verification passes in development but fails in production"

Often a signing secret mismatch. Make sure you are using the correct secret for each environment. The Stripe CLI generates a different secret to your dashboard webhook secrets.

"We are fulfilling orders twice"

Missing idempotency check. Add event ID deduplication before doing any meaningful work.

"Customer cancelled but still has access"

You are probably only listening for customer.subscription.deleted but not handling invoice.payment_failed and customer.subscription.updated (status changing to past_due or unpaid).


Stripe webhooks are not complicated once the right foundation is in place. The raw body problem trips up most teams once; after that, the pattern is solid. The bigger risk is missing events, and that is where having full visibility into every delivery attempt pays off.

If you're connecting Stripe to Webhook Watchtower, the Stripe setup guide covers test vs live mode, finding your signing secret, and common delivery issues.

Frequently asked questions

Where do I find my Stripe webhook signing secret?

In the Stripe Dashboard, open Developers, then Webhooks, and select your endpoint. The signing secret starts with whsec_ and is shown under Signing secret. Each endpoint has its own secret, and it's different in test mode and live mode, so make sure you're using the one that matches the environment sending the events.

What does 'No signatures found matching the expected signature for payload' mean?

It means Stripe's signature check failed. The most common cause is verifying against a parsed or re-serialised body instead of the exact raw request bytes, because any change to the payload breaks the signature. Other causes are using the wrong signing secret for the environment, or a body that's been altered by a proxy or body-parser middleware before it reaches your verification code.

Which Stripe webhook events should I handle?

Handle only the events your integration acts on. For one-time payments that's usually checkout.session.completed or payment_intent.succeeded; for subscriptions it's customer.subscription.created, updated, and deleted, plus invoice.paid and invoice.payment_failed. Subscribe narrowly and add events as your flows grow.

Why does Stripe say a webhook was delivered but I never received it?

Stripe marks a delivery successful once your endpoint returns a 2xx, so if you see delivered but no effect, the request usually arrived and your handler failed after responding, or a load balancer absorbed it. A capture layer that stores every raw event before processing removes the guesswork, because you can see exactly what arrived and replay it.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →