Stripe Webhooks: Complete Implementation Guide (PHP, Node.js, Python)

Updated May 1, 202616 min read

Stripe webhooks enable real-time event notifications from Stripe to your application, powering critical payment flows, subscription management, and financial reporting. This comprehensive guide covers everything you need to implement and receive Stripe webhooks securely and reliably.

You will learn how to build an endpoint, register it in the Dashboard, verify signatures with constructEventin PHP, Node.js, and Python, process events idempotently, and operate the integration reliably against Stripe's retry behavior.

What Are Stripe Webhooks?

Stripe webhooks are HTTP callbacks that Stripe sends to your application when events occur in your Stripe account. Instead of repeatedly polling Stripe's API to check for changes, webhooks push real-time notifications directly to your server.

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.

Stripe Webhook Setup Process

Step 1: Create Webhook Endpoint Handler

Your webhook endpoint must:

  • Accept POST requests with JSON payloads
  • Return HTTP 200 status code quickly (< 10 seconds)
  • Handle requests asynchronously for complex processing
  • Be accessible via HTTPS (required for production)
// 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: Configure Webhook in Stripe Dashboard

  1. Navigate to Stripe Dashboard → Developers → Webhooks
  2. Click "Add endpoint" and enter your webhook URL
  3. Select events to listen for (or choose "Select all events" for testing)
  4. Save the endpoint and copy the webhook signing secret
  5. Store the signing secret securely in your environment variables

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 contain the complete object data at the time of the event, providing full context immediately.

Thin events contain only the object ID and type, requiring you to fetch the latest data via API. Use thin events for high-volume scenarios to reduce webhook payload size.

Webhook Security: Signature Verification

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=1672531200,v1=4f4c4d4e4f4c4d4e4f4c4d4e4f4c4d4e4f4c4d4e

The signature contains a timestamp (t) and HMAC-SHA256 hash (v1) of the payload signed with your endpoint secret.

Implementation Examples

// Python with stripe library
import stripe @app.route('/stripe/webhooks', methods=['POST']) def handle_webhook(): payload = request.data 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.error.SignatureVerificationError: # Invalid signature return 'Invalid signature', 400 # Handle event return '', 200
// 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.
  • Reject events older than 300 seconds to block replay attacks that slip through a leaked secret.
  • Exclude the webhook route from CSRF middleware (Rails, Django, Laravel) — Stripe will not send a CSRF token.
  • Rotate the signing secret in the Dashboard when regenerated and deploy the new value in lockstep; accept both secrets during the cutover window.

The Receiving Flow: What Happens When a Stripe Event Fires

Understanding the exact sequence Stripe follows makes it easier to design a reliable receiver:

  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 within 10 seconds.
  4. If you respond with a non-2xx status or time out, Stripe flags the delivery as failed and schedules a retry with exponential backoff for up to three days.
  5. After enough consecutive failures, Stripe may disable the endpoint and email the account owner. Re-enabling it requires manual action in the Dashboard.

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.
  • 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.

Testing Stripe Webhooks

Using Stripe CLI

The Stripe CLI is the best tool for webhook development and testing:

# 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 CLI provides a webhook signing secret for local testing and shows real-time webhook delivery status.

Testing Strategies

Local Development

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

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 duplicate webhooks gracefully by tracking processed event IDs:

async function processWebhook(event) { // Check if we've already processed this event const existing = await db.findProcessedEvent(event.id); if (existing) { console.log('Duplicate webhook ignored:', event.id); return; } // Process the event await handleEvent(event); // Mark as processed await db.saveProcessedEvent(event.id); }

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

Stripe Webhooks Reliability: Retries, Idempotency & Operational Playbook

Stripe guarantees at-least-once delivery — never exactly-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.

Stripe retries failed deliveries on an exponential backoff schedule for up to three days. If retries continue to fail, the endpoint is disabled and must be re-enabled manually from the Dashboard. 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 waits up to 10 seconds for a 2xx response. After 10s, the delivery is considered failed even if your handler eventually completes.
  • Retry schedule: Failures are retried with exponential backoff over roughly three days. Spacing increases from minutes to hours.
  • 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: Endpoints with sustained failures are disabled; the account owner receives an email. Monitor your Dashboard webhook logs to avoid silent disablement in production.

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 toward the 10s budget is an early warning.
  • Emit a metric every time you short-circuit on a duplicate event ID; a sudden spike signals an infrastructure retry loop.
  • Export Stripe's Dashboard delivery log to your observability stack weekly — the Dashboard view only surfaces the last three days.
  • 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.

Debug Stripe Webhooks with Hooklistener

Hooklistener provides the ultimate webhook debugging platform for Stripe integrations. Capture, inspect, and replay webhook events with signature verification, retry tracking, and team collaboration features.

Real-time webhook capture and inspection
Stripe signature verification
Event replay and testing tools
Retry and failure monitoring
Start Debugging Stripe Webhooks →

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 your endpoint signing secret. The SDK handles constant-time HMAC comparison and the 5-minute timestamp tolerance automatically.

Why is my Stripe webhook signature verification failing?

The most common cause is JSON middleware, such as Express body-parser, Laravel's input helper, or Django's request.POST, re-serializing the payload before it reaches constructEvent. Always read the raw request bytes. Other causes include using the wrong signing secret, clock skew beyond the 5-minute tolerance, or a proxy stripping the Stripe-Signature header.

What is the Stripe webhook timeout limit?

Stripe waits up to 10 seconds for a 2xx HTTP response. If your handler does not reply within 10 seconds, the delivery is marked failed and scheduled for retry. Best practice: verify the signature, enqueue the event to a background job system such as Sidekiq, Celery, BullMQ, or SQS, and return 200 immediately. Never run slow operations such as email sends or ERP syncs inline.

How do I handle Stripe webhook retries and duplicate events?

Stripe guarantees at-least-once delivery and retries failures with exponential backoff for up to 72 hours, so the same event.id can arrive multiple times. Store each processed event.id in a database table with a UNIQUE constraint and short-circuit before mutating state if the ID already exists. This idempotency check prevents double-fulfillment, duplicate refunds, and repeated emails during retry storms.

How do I test Stripe webhooks locally?

Use the Stripe CLI: run `stripe listen --forward-to localhost:3000/webhooks` to forward real events to your local server, and `stripe trigger payment_intent.succeeded` to fire specific test events. The CLI provides a local signing secret for signature verification. Alternatively, use a tunnel service to expose your local port via a public HTTPS URL that you register as a Stripe webhook endpoint.

Which Stripe webhook events should I subscribe to?

Subscribe only to events your application actually handles. Core events for most integrations: payment_intent.succeeded, payment_intent.payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, and invoice.payment_failed. Avoiding unused event types reduces noise and processing overhead.

Related Webhook Resources