Stripe Webhooks Implementation Guide (2026): Node.js, Next.js, Python and PHP

Updated September 26, 202617 min read

To implement Stripe webhooks, register an HTTPS endpoint in Workbench, verify every request with constructEvent using the raw body, the Stripe-Signature header and the endpoint's whsec_ secret, return a 2xx quickly, and process each event.id exactly once in a background job.

This guide covers building and registering the endpoint, signature verification in Node.js (Express and Next.js), Python (Flask and FastAPI) and PHP, idempotent processing, and how Stripe's retry behavior should shape your handler.

Key takeaways

  • Verify with the SDK, on the raw body. stripe.webhooks.constructEvent(rawBody, sigHeader, whsec) fails if any middleware parsed the JSON first.
  • The default tolerance is 5 minutes. Stripe's libraries reject a Stripe-Signature timestamp older than 300 seconds; every retry is re-signed with a new timestamp.
  • Each secret is scoped. The stripe listen secret, the sandbox endpoint secret and the live endpoint secret are three different whsec_ values.
  • Expect duplicates and disorder. Live-mode retries run for up to three days, and Stripe does not guarantee event order, so dedupe on event.id and re-fetch objects when order matters.
  • Acknowledge fast, work later. Stripe publishes no exact timeout; verify, enqueue, and return 200 before any slow work.
  • Debug with real deliveries. Point a sandbox endpoint at a Hooklistener URL to capture the exact bytes Stripe sent, then replay that event to localhost re-signed with your secret.

What are Stripe webhooks?

Stripe webhooks are HTTPS POST requests that Stripe sends to your server when an event happens in your account, each carrying a JSON Event object and a Stripe-Signature header. Instead of polling Stripe's API for changes, you react to events as they are pushed.

Common webhook events include successful payments, failed charges, subscription updates, customer changes, and dispute notifications. Webhooks are essential for keeping your application synchronized with Stripe's data.

How do you set up a Stripe webhook endpoint?

Write a POST handler that verifies the signature on the raw body and returns 2xx quickly, then register its public HTTPS URL in Workbench (or through the API) and store the whsec_ signing secret it gives you.

Step 1: Create Webhook Endpoint Handler

Your webhook endpoint must:

  • Accept POST requests with JSON payloads
  • Return a 2xx status quickly, before any slow logic (Stripe publishes no exact timeout, so aim for well under a few seconds)
  • Handle requests asynchronously for complex processing
  • Be accessible via HTTPS with TLS 1.2 or 1.3 (required in live mode) and not redirect: Stripe counts a 3xx as a failed delivery
// Node.js Express example
app.post('/stripe/webhooks', express.raw({type: 'application/json'}), (request, response) => { const sig = request.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent(request.body, sig, endpointSecret); } catch (err) { console.log(`Webhook signature verification failed.`, err.message); return response.status(400).send(`Webhook Error: ${err.message}`); } // Handle the event switch (event.type) { case 'payment_intent.succeeded': const paymentIntent = event.data.object; console.log('PaymentIntent was successful!'); break; case 'customer.subscription.deleted': const subscription = event.data.object; console.log('Subscription was deleted'); break; default: console.log(`Unhandled event type ${event.type}`); } response.json({received: true}); });

Step 2: Register the endpoint in Workbench

  1. Open the Webhooks tab in Workbench (it replaced the Developers Dashboard) and create an event destination
  2. Choose the API version for the Event objects and select only the event types you handle
  3. Pick “Webhook endpoint” as the destination type and enter your public HTTPS URL
  4. On the endpoint page, reveal the signing secret (it starts with whsec_)
  5. Store it in your secret manager or environment, separately for sandbox and live mode

An account can register up to 16 webhook endpoints. You can also create them with the /v2/core/event_destinations API. Source: Stripe webhooks docs, checked September 2026.

Understanding Stripe Webhook Events

Event Types

Payment Events

  • • payment_intent.succeeded
  • • payment_intent.payment_failed
  • • charge.succeeded
  • • charge.dispute.created

Subscription Events

  • • customer.subscription.created
  • • customer.subscription.updated
  • • customer.subscription.deleted
  • • invoice.payment_succeeded

Snapshot vs Thin Events

Snapshot events are the classic v1 Event objects: data.object holds a copy of the object at the time of the event, shaped by the endpoint's API version.

Thin events are used for API v2 resources. They carry a reference to the related object rather than a full copy, so you fetch the latest state with fetchRelatedObject(). Thin events need their own endpoint (event_payload: "thin") and locally you forward them with stripe listen --forward-thin-to.

How do you verify Stripe webhook signatures?

Call your Stripe SDK's constructEvent with the raw request body, the Stripe-Signature header and the endpoint secret; it throws if the HMAC does not match or the timestamp is older than 5 minutes. Every example below follows that rule.

Critical Security Practice

Always verify webhook signatures to ensure requests actually come from Stripe. Without verification, malicious actors could send fake webhook events to your endpoint.

How Stripe Signatures Work

Stripe includes a Stripe-Signature header with each webhook:

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39

t is the Unix timestamp of this delivery attempt and v1 is the hex HMAC-SHA256 of {t}.{raw body} keyed with your whsec_ secret. v1 is the only valid live-mode scheme; v0 is a fake signature Stripe adds to test events, and you should ignore every scheme except v1. During a secret roll the header carries one v1 per active secret.

Implementation Examples

Python (Flask)

# Python with stripe library
import stripe @app.route('/stripe/webhooks', methods=['POST']) def handle_webhook(): payload = request.get_data() # raw bytes, not request.get_json() sig_header = request.headers.get('Stripe-Signature') try: event = stripe.Webhook.construct_event( payload, sig_header, endpoint_secret ) except ValueError: # Invalid payload return 'Invalid payload', 400 except stripe.SignatureVerificationError: # Invalid signature or timestamp outside the 300 s tolerance return 'Invalid signature', 400 # Handle event return '', 200

Python (FastAPI)

In FastAPI, read await request.body(); declaring a Pydantic model or calling request.json() first gives you parsed data that no longer matches the signed bytes.

import os
import stripe
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
ENDPOINT_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/stripe/webhooks")
async def stripe_webhook(
    request: Request,
    stripe_signature: str | None = Header(default=None),
):
    payload = await request.body()  # raw bytes
    try:
        event = stripe.Webhook.construct_event(
            payload, stripe_signature, ENDPOINT_SECRET
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="invalid payload")
    except stripe.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="invalid signature")

    await enqueue(event)  # hand off to a worker, then acknowledge
    return {"received": True}

Node.js (Next.js App Router)

Route handlers do not parse the body for you, so await req.text() returns the exact string Stripe signed. Do not call req.json() before verifying.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

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

export async function POST(req: Request) {
  const body = await req.text(); // raw body
  const signature = req.headers.get("stripe-signature");

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

  if (event.type === "checkout.session.completed") {
    await enqueueFulfillment(event.id, event.data.object.id);
  }
  return Response.json({ received: true });
}

On the Edge runtime, use await stripe.webhooks.constructEventAsync(...), which uses the Web Crypto API.

PHP

// PHP with stripe-php — full constructEvent flow
require_once 'vendor/autoload.php'; \Stripe\Stripe::setApiKey(getenv('STRIPE_API_KEY')); $endpoint_secret = getenv('STRIPE_WEBHOOK_SECRET'); // IMPORTANT: use the raw request body — never json_decode before verifying $payload = @file_get_contents('php://input'); $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; try { $event = \Stripe\Webhook::constructEvent( $payload, $sig_header, $endpoint_secret ); } catch (\UnexpectedValueException $e) { // Malformed JSON http_response_code(400); exit(); } catch (\Stripe\Exception\SignatureVerificationException $e) { // Bad signature or stale timestamp (>5min) http_response_code(400); exit(); } // Deduplicate — Stripe delivers at-least-once if (already_processed($event->id)) { http_response_code(200); exit(); } switch ($event->type) { case 'payment_intent.succeeded': $intent = $event->data->object; // \Stripe\PaymentIntent fulfill_order($intent); break; case 'customer.subscription.deleted': cancel_subscription($event->data->object); break; default: error_log('Unhandled Stripe event: ' . $event->type); } mark_processed($event->id); http_response_code(200);

Signature verification checklist (applies to every language):

  • Always use the raw request body. JSON middleware (Express body-parser, Django request.POST, Laravel's default JSON parsing) silently re-serializes the payload and breaks HMAC verification.
  • Use the official SDK's constructEvent — it performs constant-time comparison and enforces a default 5-minute timestamp tolerance so you do not have to hand-roll timing-safe comparisons.
  • Keep the tolerance at 300 seconds or tighter, never 0 (Stripe warns that 0 disables the recency check), and keep server clocks NTP-synced.
  • Exclude the webhook route from CSRF middleware (Rails, Django, Laravel) — Stripe will not send a CSRF token.
  • Roll the signing secret from the endpoint's menu in Workbench. You can keep the old secret active for up to 24 hours; Stripe signs each delivery with every active secret, so deploy the new whsec_ anywhere inside that window.

What happens when a Stripe event fires?

Stripe creates an Event, POSTs it to every endpoint subscribed to that type, and retries on anything but a timely 2xx. Step by step:

  1. An event occurs in your Stripe account (a charge succeeds, a subscription renews, a dispute opens).
  2. Stripe enqueues the event and POSTs it to every registered endpoint subscribed to that event type, over HTTPS with a JSON body.
  3. Your endpoint reads the raw body, validates the Stripe-Signature header against the endpoint signing secret, and returns a 2xx status quickly.
  4. If you respond with a non-2xx status (redirects included) or time out, Stripe marks the delivery failed and retries with exponential backoff for up to three days in live mode, or three times over a few hours in a sandbox. Each attempt gets a fresh timestamp and signature.
  5. Stripe emails you when an endpoint keeps failing, and a persistently failing endpoint can end up disabled. Once an endpoint is disabled or deleted, pending retries for it stop.

Two design rules follow directly from this flow:

  • Respond fast, process later. Verify the signature, enqueue the work (Sidekiq, Celery, SQS, a durable DB queue), and return 200 immediately. Never run invoice emails, ERP syncs, or external API calls inline. Stripe's own guidance is to return 2xx before, for example, marking an invoice paid in your accounting system.
  • Assume duplicates. Retries and out-of-order delivery mean you will receive the same event.id more than once. Persist processed event IDs and short-circuit on a match before mutating state.

Stripe Webhook Security Testing Best Practices

Hardening a Stripe webhook handler is a deep topic in its own right: unit-testing signature verification in CI, preventing replay attacks with event-ID nonce tracking, avoiding timing-attack pitfalls in hand-rolled HMAC code, and layering defense-in-depth at the edge. Each of those deserves more room than fits in an implementation walkthrough.

The single most important rule: always use stripe.Webhook.construct_event (Python) or stripe.webhooks.constructEvent (Node.js) — never hand-roll HMAC verification. The official SDKs handle constant-time comparison, timestamp tolerance, and header parsing correctly so you do not have to.

For a full treatment — signature verification examples in Python, Node.js, Ruby, and Go, CI unit-test patterns, replay and timing-attack mitigations, and the endpoint hardening checklist — see our dedicated Stripe Webhook Security Guide. For cross-provider signing and ephemeral token patterns, see our general webhook security guide, and use our webhook tester to inspect incoming Stripe payloads during local development.

How do you test Stripe webhooks locally?

Using Stripe CLI

Run stripe listen --forward-to to forward events to your local server, use the whsec_ secret it prints, and fire events with stripe trigger:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to local development
stripe listen --forward-to localhost:3000/stripe/webhooks
# Trigger specific test events
stripe trigger payment_intent.succeeded

The secret printed by stripe listen is different from your Dashboard endpoint secrets; verifying CLI-forwarded events with a Dashboard secret (or the reverse) is the most common cause of “No signatures found matching the expected signature”.

Testing Strategies

Local Development

  • • Use Stripe CLI for forwarding
  • • Test with generated test events
  • • Verify signature validation
  • • Check idempotency handling with stripe events resend

Production Testing

  • • Use Stripe test mode initially
  • • Monitor webhook delivery logs
  • • Test failure scenarios
  • • Validate retry behavior

Production Best Practices

Handle Events Asynchronously

Return HTTP 200 immediately and process webhooks in the background to avoid timeouts:

// Queue webhook for background processing app.post('/webhooks', (req, res) => { // Verify signature first const event = verifyWebhook(req.body, req.headers); // Queue for background processing webhookQueue.add('process-stripe-webhook', event); // Return success immediately res.json({received: true}); });

Implement Idempotency

Handle duplicates by claiming each event.id atomically before doing any work. A “look it up, then insert” check races when a retry lands while the first delivery is still running; a UNIQUE constraint does not:

-- Postgres: the INSERT is the lock
INSERT INTO stripe_events (event_id, type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;   -- no row returned = duplicate, skip it

Stripe notes that occasionally two separate Event objects are generated for the same change. If that matters for your side effects, also dedupe on data.object.id plus event.type. See webhook idempotency and deduplication for Redis and crash-safe variants.

Monitor and Alert

  • • Track webhook processing success/failure rates
  • • Alert on signature verification failures
  • • Monitor processing times and queue depths
  • • Log failed events for manual review

How do Stripe webhook retries work, and how do you stay reliable?

Stripe delivers webhooks at least once, never exactly once: its docs state that endpoints might receive the same event more than once. That single fact drives every operational decision around a Stripe webhook integration. A receiver that is not idempotent will double-fulfill orders, double-credit refunds, and send duplicate receipts during every retry storm.

In live mode, Stripe retries failed deliveries on an exponential backoff schedule for up to three days; sandbox events are retried three times over a few hours. Stripe emails you when an endpoint keeps failing. Reliability work, therefore, is primarily about (1) responding fast enough to avoid timeouts, (2) deduplicating on the server, and (3) monitoring the delivery stream so you catch a problem before Stripe gives up.

Retry behavior and timeout budget

  • Timeout: Stripe does not publish an exact timeout. A delivery that takes too long is marked “Timed out” and retried even if your handler eventually completes, so budget for a response in well under a few seconds.
  • Retry schedule: Live-mode failures are retried with exponential backoff for up to three days; the next retry time is shown on the endpoint's Event deliveries tab.
  • Manual resends: Resend from the Dashboard up to 15 days after the event was created, or with stripe events resend <event_id> --webhook-endpoint=<endpoint_id> up to 30 days. A successful manual resend does not cancel the automatic retries.
  • Out-of-order delivery: Retries mean a newer event can arrive before the retry of an older one. Never assume ordering — re-fetch the canonical object from the API when ordering matters.
  • Disablement: Stripe emails you about endpoints that keep failing, and a persistently failing endpoint can be disabled. Watch the Event deliveries tab and your own error rate so this never happens silently.

Idempotency pattern

// Node.js: idempotent processing with a persisted ledger
async function processWebhook(event) {
  // 1. Atomic insert into a processed-events table (UNIQUE on event.id)
  const inserted = await db.insertIfAbsent("stripe_processed_events", {
    event_id: event.id,
    type: event.type,
    received_at: new Date(),
  });

  if (!inserted) {
    // Duplicate — another worker already handled it
    return;
  }

  try {
    await handleEvent(event); // fulfill, refund, update subscription
    await db.markProcessed(event.id);
  } catch (err) {
    // Release the claim so a future retry can try again
    await db.deleteProcessedEvent(event.id);
    throw err;
  }
}

Reliability monitoring checklist

  • Alert on any non-2xx response rate above 1% over a 15-minute window.
  • Track p95 handler latency — a steady climb past one or two seconds is an early warning of timeouts.
  • Emit a metric every time you short-circuit on a duplicate event ID; a sudden spike signals an infrastructure retry loop.
  • Log every received event.id in your own store; Stripe's List Events API only returns events from the last 30 days, which is also the window for backfilling missed deliveries.
  • Ship a synthetic that triggers stripe trigger payment_intent.succeeded against staging and asserts end-to-end fulfillment in under five seconds.

For deeper coverage of retry storms, dead-letter queues, and cross-provider reliability patterns, see the realtime webhooks reliability guide. During integration work, use Hooklistener's webhook tester and webhook inbox to capture and replay real Stripe deliveries without rebuilding tunnels.

Common Stripe Webhook Challenges

Challenges and Solutions:

  • Timeout Issues: Long processing causes Stripe to retry. Always return 200 quickly and process asynchronously.
  • Signature Verification: Failing verification breaks webhook processing. Test thoroughly with different payloads.
  • Event Ordering: Webhooks may arrive out of order. Don't rely on processing order for business logic.
  • Retry Storms: Failing webhooks are retried automatically. Fix processing issues quickly to prevent backlog.

Test your Stripe webhook handler with Hooklistener

The Stripe CLI is the fastest way to generate events. Hooklistener adds the part the CLI does not keep: a stored copy of each real delivery that you can inspect, verify and replay on demand, from the dashboard or from your AI assistant through theHooklistener MCP server.

  1. Capture. Create a Hooklistener endpoint, add its URL as a sandbox webhook endpoint in Workbench, and run stripe trigger checkout.session.completed. The raw body and Stripe-Signature header are stored byte-for-byte.
  2. Verify. Store the endpoint's whsec_ with create_secret and run verify_request_signature with provider: "stripe". A wrong secret and a stale timestamp are reported separately.
  3. Replay to localhost. Run hooklistener listen --endpoint <ENDPOINT_ID> --port 3000 and replay the event with replay_request (target: "cli"), re-signed with your stored secret so the signature timestamp is fresh.
  4. Lock it in. Save the delivery with save_request_case, add a copy with a new evt_ ID, and run both with run_endpoint_cases after every handler change: the duplicate must return 200 without side effects, the new ID must be processed.

Capture and replay are on every plan, including Free; stored secrets for verification and re-signing are on paid plans.

Frequently Asked Questions

How do I verify a Stripe webhook signature?

Use Stripe's official SDK: stripe.webhooks.constructEvent() in Node.js, stripe.Webhook.construct_event() in Python, or \Stripe\Webhook::constructEvent() in PHP. Pass the raw request body (never a JSON-parsed payload), the Stripe-Signature header, and the endpoint's whsec_ signing secret. The SDK compares signatures in constant time and rejects timestamps older than its default 300-second tolerance.

Why is my Stripe webhook signature verification failing?

The usual cause is a framework parsing the body before constructEvent sees it: express.json() mounted before the webhook route, req.json() in a Next.js route handler, or request.json() in FastAPI. Read the raw bytes instead (express.raw(), await req.text(), await request.body()). The second most common cause is the wrong secret: the stripe listen secret, the sandbox endpoint secret and the live endpoint secret are all different whsec_ values.

How long does Stripe wait for a webhook response?

Stripe does not publish an exact timeout; its docs say to return a 2xx quickly, before any complex logic. Treat anything over a few seconds as risky: verify the signature, enqueue the event (BullMQ, Celery, Sidekiq, SQS or a Postgres job table), and return 200 immediately. Timed-out deliveries are marked failed and retried.

How do I handle Stripe webhook retries and duplicate events?

In live mode Stripe retries failed deliveries with exponential backoff for up to three days (sandbox events are retried three times over a few hours), so the same event.id can arrive more than once. Insert each event.id into a table with a UNIQUE constraint using INSERT ... ON CONFLICT DO NOTHING and skip the event when no row is inserted. Every retry carries a fresh Stripe-Signature timestamp, so a retry still passes the 5-minute tolerance.

How do I test Stripe webhooks locally?

Run stripe listen --forward-to localhost:3000/webhooks, copy the whsec_ secret it prints into your environment, and fire events with stripe trigger payment_intent.succeeded. To test with the exact bytes of a real sandbox delivery instead, point a Stripe endpoint at a Hooklistener URL, then replay the captured event to localhost with the Hooklistener CLI, re-signed with your secret so the timestamp is fresh.

Which Stripe webhook events should I subscribe to?

Subscribe only to events your application actually handles. Core events for most integrations: checkout.session.completed, payment_intent.succeeded, payment_intent.payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid and invoice.payment_failed. Stripe recommends against listening to all events because it adds load without value.

Related Webhook Resources