Skip to content

Webhooks Fresh

Receive real-time notifications when events happen in your Stripe account. Webhooks are how your integration reacts to asynchronous events like successful payments, subscription renewals, and disputes.

How webhooks work

  1. An event occurs in Stripe (payment succeeds, subscription renews, etc.)
  2. Stripe sends an HTTPS POST to your webhook endpoint URL
  3. The request body contains an Event object in JSON
  4. Your endpoint returns a 2xx status within the timeout window
  5. If Stripe doesn't receive a 2xx, it retries with exponential backoff

Create a webhook handler

javascript
const express = require('express');
const stripe = require('stripe')('sk_test_YOUR_KEY');
const app = express();

// IMPORTANT: Use express.raw() for webhooks — not express.json()
// express.json() modifies the body, breaking signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Return 200 quickly, then process the event
  res.json({ received: true });

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      handlePaymentSuccess(paymentIntent);
      break;

    case 'payment_intent.payment_failed':
      const failedPaymentIntent = event.data.object;
      handlePaymentFailure(failedPaymentIntent);
      break;

    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      const subscription = event.data.object;
      handleSubscriptionChange(subscription);
      break;

    case 'customer.subscription.deleted':
      const canceledSubscription = event.data.object;
      handleSubscriptionCanceled(canceledSubscription);
      break;

    case 'invoice.paid':
      const invoice = event.data.object;
      handleInvoicePaid(invoice);
      break;

    case 'invoice.payment_failed':
      const failedInvoice = event.data.object;
      handleInvoicePaymentFailed(failedInvoice);
      break;

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

app.listen(4242);

Register your webhook endpoint

Create a webhook endpoint in Dashboard → Developers → Webhooks → Add endpoint.

Or via API:

bash
curl https://api.stripe.com/v1/webhook_endpoints \
  -u "sk_test_YOUR_KEY:" \
  -d url="https://example.com/webhook" \
  -d "enabled_events[]=payment_intent.succeeded" \
  -d "enabled_events[]=invoice.paid" \
  -d "enabled_events[]=customer.subscription.deleted"

Signature verification

Always verify the Stripe-Signature header to confirm events come from Stripe:

javascript
// The three parameters must be exactly right:
// 1. req.body - raw bytes, not parsed JSON
// 2. sig - the Stripe-Signature header value
// 3. endpointSecret - starts with whsec_

event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);

Common signature verification errors

ErrorCauseFix
No signatures found matching...Wrong endpoint secretUse the secret from Dashboard → Webhooks → your endpoint → Reveal secret
Timestamp outside the tolerance zoneRequest body was buffered too longReturn 200 before complex processing
Verification fails in ExpressBody parser applied before webhook routePut webhook route BEFORE app.use(express.json())
Verification fails with Next.js App RouterBody parsed as JSON by Next.jsUse await request.text() or await request.arrayBuffer() to get raw body

Express middleware order (critical)

javascript
// CORRECT: webhook route BEFORE body parser
app.post('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
app.use(express.json()); // This comes AFTER webhook route
app.post('/api/other-route', otherHandler);

// WRONG: body parser applied first breaks webhook signature
app.use(express.json()); // This breaks webhook verification
app.post('/webhook', webhookHandler);

Next.js App Router

javascript
// app/api/webhooks/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';

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

export async function POST(req: Request) {
  const body = await req.text();
  const sig = headers().get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }

  // Handle event
  return new Response(JSON.stringify({ received: true }));
}

Endpoint secret types

SecretStarts withUse
Dashboard endpoint secretwhsec_...For endpoints registered in Dashboard
Stripe CLI secretwhsec_...Printed when running stripe listen — different from Dashboard secret

Do not use a Dashboard secret to verify CLI-forwarded events, or vice versa.

Testing locally with Stripe CLI

bash
# Install Stripe CLI, then:
stripe listen --forward-to localhost:4242/webhook

# In another terminal, trigger test events:
stripe trigger payment_intent.succeeded
stripe trigger invoice.paid
stripe trigger customer.subscription.created

The CLI prints the webhook secret (whsec_...) when you run stripe listen. Use that as your STRIPE_WEBHOOK_SECRET during local development.

Retry behavior

If your endpoint doesn't return a 2xx within 30 seconds, Stripe retries with exponential backoff over the next 3 days.

Retry schedule (approximate):

  • 5 minutes
  • 30 minutes
  • 2 hours
  • 5 hours
  • 10 hours
  • 24 hours (then daily until 3 days)

Idempotency in webhook handlers

Stripe may send the same event more than once. Make your handlers idempotent:

javascript
// Track processed event IDs to avoid double-processing
const processedEvents = new Set();

async function handleWebhookEvent(event) {
  if (processedEvents.has(event.id)) {
    return; // Already processed
  }

  // Process the event
  await processEvent(event);

  // Mark as processed
  processedEvents.add(event.id);
  // In production: store in database with TTL
}

Event object structure

json
{
  "id": "evt_1Nxxx",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": 1234567890,
  "livemode": false,
  "data": {
    "object": { ... },         // The Stripe object that triggered the event
    "previous_attributes": {}  // Changed fields (only on *.updated events)
  },
  "request": {
    "id": "req_xxx",
    "idempotency_key": null
  },
  "account": "acct_xxx"       // Only on Connect events — which connected account
}

Common webhook events

EventWhen it fires
payment_intent.succeededPayment confirmed and funds captured
payment_intent.payment_failedPayment failed
payment_intent.processingPayment processing (bank transfers)
invoice.paidInvoice payment succeeded
invoice.payment_failedInvoice payment failed
invoice.finalizedInvoice finalized and ready to send
customer.subscription.createdNew subscription created
customer.subscription.updatedSubscription changed
customer.subscription.deletedSubscription canceled
checkout.session.completedCheckout session completed
charge.dispute.createdDispute opened
charge.refundedCharge refunded
account.updatedConnected account updated
payout.paidPayout successfully sent to bank
payout.failedPayout failed

Stripe API Reference - Self-contained docs reference, refreshed 2026-05-18