Home/Blog/Stripe Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

Stripe Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Stripe webhooks with setup instructions, payload examples, signature verification, and implementation code. Learn how to integrate Stripe webhooks into your application with step-by-step tutorials for payment processing and subscription management.

By Inventive HQ Team
Stripe Webhooks: Complete Guide with Payload Examples [2025]

When a customer completes a payment on your platform, you need to know immediately—not in 5 minutes when your polling script runs again. Stripe webhooks solve this problem by sending real-time HTTP notifications to your server the moment events occur, enabling you to fulfill orders instantly, send confirmation emails, update subscription statuses, and trigger critical business workflows without delay.

Whether you're building a SaaS subscription platform, an e-commerce checkout, a marketplace payment system, or any application that processes payments through Stripe, webhooks are essential for creating responsive, reliable payment experiences. Unlike API polling which wastes resources checking for changes that might not exist, webhooks push data to your application only when something happens.

Common Stripe webhook use cases:

  • Fulfill orders when payments succeed (payment_intent.succeeded)
  • Send failure notifications and retry logic (payment_intent.payment_failed)
  • Activate subscriptions when customers sign up (customer.subscription.created)
  • Cancel access when subscriptions end (customer.subscription.deleted)
  • Process refund workflows (charge.refunded)
  • Update customer records in real-time (customer.updated)
  • Handle failed payments and dunning management (invoice.payment_failed)

In this comprehensive guide, you'll learn everything needed to implement production-ready Stripe webhooks: setting up endpoints in the Stripe Dashboard, understanding webhook payload structures, implementing secure signature verification with HMAC-SHA256, handling different event types, testing locally with the Stripe CLI, and following best practices for reliability and security.

Want to test your implementation before going live? Our Webhook Payload Generator creates realistic Stripe webhook payloads with valid signatures, allowing you to test your verification logic without needing live Stripe events.

What Are Stripe Webhooks?

Stripe webhooks are automated HTTP POST requests that Stripe sends to your server whenever specific events occur in your Stripe account—like when a payment succeeds, a subscription is created, or a refund is processed. Instead of your application repeatedly polling Stripe's API to check for changes, Stripe proactively notifies you in real-time when something important happens.

How Stripe webhooks work:

[Payment Completes] → [Stripe Server] → [Your Webhook Endpoint] → [Your Application Logic]

When an event occurs (like a successful payment), Stripe's servers immediately send a JSON payload containing all relevant data to your webhook endpoint URL. Your server receives this HTTP POST request, verifies it came from Stripe using cryptographic signatures, and then executes your business logic—updating databases, sending emails, fulfilling orders, or triggering other workflows.

What makes Stripe webhooks different:

Stripe implements particularly robust webhook security through HMAC-SHA256 signature verification with timestamp validation. Unlike some providers that use simpler signature schemes, Stripe's approach includes a timestamp in the signed payload specifically to prevent replay attacks—where an attacker intercepts and resends old webhook requests. Stripe's official SDKs across all major programming languages handle this complex verification automatically.

Key benefits of Stripe webhooks:

  • Real-time responsiveness - Know about payments, subscription changes, and refunds instantly
  • Reduced API calls - No need to poll endpoints repeatedly checking for changes
  • Reliable event tracking - Stripe queues and retries failed deliveries for up to 3 days
  • Comprehensive event coverage - Track hundreds of event types across payments, subscriptions, customers, disputes, and more
  • Built-in debugging - Dashboard shows delivery attempts, response codes, and retry history

Prerequisites for Stripe webhooks:

Before implementing Stripe webhooks, you need:

  • An active Stripe account (test mode works for development)
  • A publicly accessible HTTPS endpoint (localhost works for testing with Stripe CLI)
  • Server-side code that can receive HTTP POST requests
  • Your webhook signing secret from the Stripe Dashboard (used for signature verification)

Stripe webhooks enable event-driven architecture for payment processing, making your applications more responsive and reliable while reducing server load from unnecessary API polling.

Setting Up Stripe Webhooks

Stripe makes webhook setup straightforward through their Dashboard interface. Follow these steps to create a webhook endpoint and start receiving events.

Step 1: Access Webhook Settings

Log in to your Stripe Dashboard and navigate to Developers in the top navigation menu. Click Webhooks from the submenu. You'll see any existing webhook endpoints and a button to add new ones.

Step 2: Add a New Endpoint

Click the "Add endpoint" button in the top right. Stripe will prompt you for your webhook URL.

Step 3: Configure Your Endpoint URL

Enter your webhook endpoint URL in the format:

https://yourdomain.com/webhooks/stripe

Important considerations:

  • HTTPS required - Production endpoints must use HTTPS (not HTTP)
  • Publicly accessible - Stripe's servers must be able to reach your URL
  • For local testing - Use the Stripe CLI to forward events to localhost (covered in testing section)

Step 4: Select Events to Receive

Stripe offers two options:

  • Listen to all events - Receive every event type Stripe generates (hundreds of events)
  • Select events - Choose specific events relevant to your application (recommended)

For most payment and subscription applications, select these events:

Payment Events:

  • payment_intent.succeeded - Payment completed successfully
  • payment_intent.payment_failed - Payment attempt failed
  • charge.refunded - Refund processed
  • charge.succeeded - Funds captured

Subscription Events:

  • customer.subscription.created - New subscription started
  • customer.subscription.updated - Subscription modified (plan change, status update)
  • customer.subscription.deleted - Subscription canceled or ended
  • invoice.paid - Subscription invoice paid successfully
  • invoice.payment_failed - Subscription payment failed

Click "Add events" after selecting your event types.

Step 5: Save and Retrieve Your Signing Secret

Click "Add endpoint" to save your webhook configuration. Stripe will generate a webhook signing secret that looks like:

whsec_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz

Critical: Store this secret securely in your environment variables. You'll use it to verify webhook signatures in your code. Never commit this secret to version control or expose it in client-side code.

Add it to your .env file:

STRIPE_WEBHOOK_SECRET=whsec_your_actual_secret_here

Step 6: Test Your Endpoint (Optional)

Stripe provides a "Send test webhook" button in the Dashboard. Click it to send a sample event to your endpoint. Check that your server receives the request and returns a 200 status code.

Pro Tips for Stripe Webhook Setup

Separate test and production endpoints - Create different webhook URLs for test mode and live mode with different signing secrets. This prevents test events from affecting production data.

Start with fewer events - Don't subscribe to "all events" initially. Add only the events you're actively handling, then expand as needed.

Note your endpoint's event types - Document which events each endpoint handles, especially if you create multiple endpoints for different parts of your application.

Use descriptive endpoint descriptions - Stripe allows adding descriptions to endpoints. Use this to note what each endpoint does (e.g., "Production subscription management" or "Test payment processing").

Check rate limits - While Stripe doesn't strictly rate-limit webhooks, your server should handle the volume of events you'll receive. High-volume businesses might receive thousands of events per hour.

Monitor the Events log - The Stripe Dashboard's Events section shows all events generated in your account. Use this to debug webhook issues by comparing event data with what your endpoint received.

With your webhook endpoint configured in Stripe, you're ready to implement the server-side code to receive and process these events securely.

Stripe Webhook Events & Payloads

Stripe generates hundreds of different event types covering payments, subscriptions, customers, disputes, payouts, and more. Understanding webhook payload structures helps you extract the data needed for your business logic.

Common Stripe Webhook Events

Event TypeDescriptionCommon Use Case
payment_intent.succeededPaymentIntent completed successfullyFulfill orders, send confirmation emails
payment_intent.payment_failedPayment attempt failedNotify customer, update order status
customer.subscription.createdNew subscription startedActivate user account, grant access
customer.subscription.updatedSubscription modifiedHandle plan changes, status updates
customer.subscription.deletedSubscription ended or canceledRevoke access, send cancellation notice
charge.refundedRefund processed (full or partial)Update inventory, notify customer
charge.succeededCharge captured successfullyConfirm payment received
invoice.paidInvoice payment succeededConfirm subscription renewal
invoice.payment_failedInvoice payment failedDunning management, retry logic
customer.createdNew customer record createdWelcome emails, CRM sync
customer.updatedCustomer information changedUpdate local records

Stripe Webhook Payload Structure

All Stripe webhook payloads follow the same Event object structure:

{
  "id": "evt_1234567890abcdef",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067200,
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      // The actual resource (PaymentIntent, Subscription, etc.)
    },
    "previous_attributes": {
      // For *.updated events, shows what changed
    }
  },
  "livemode": false,
  "pending_webhooks": 1,
  "request": {
    "id": "req_abc123",
    "idempotency_key": null
  }
}

Key fields in every Stripe webhook:

  • id - Unique event identifier (use for idempotency)
  • type - Event type (e.g., "payment_intent.succeeded")
  • created - Unix timestamp when event occurred
  • data.object - The full resource that triggered the event
  • livemode - Boolean indicating test (false) or production (true)
  • pending_webhooks - Number of pending delivery attempts

Detailed Event Examples

Event: payment_intent.succeeded

Description: Fires when a PaymentIntent successfully completes payment. This is the primary event for confirming payments and fulfilling orders.

Payload Structure:

{
  "id": "evt_3MtwBwLkdIwHu7ix0SNd0F15",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067200,
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_3MtwBwLkdIwHu7ix0K7h6B0v",
      "object": "payment_intent",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_NffrFeUfNV2Hib",
      "payment_method": "pm_1MtwBwLkdIwHu7ix0Q0k7iTz",
      "charges": {
        "object": "list",
        "data": [
          {
            "id": "ch_3MtwBwLkdIwHu7ix0K7h6B0v",
            "object": "charge",
            "amount": 2000,
            "currency": "usd",
            "status": "succeeded",
            "receipt_url": "https://pay.stripe.com/receipts/..."
          }
        ]
      },
      "metadata": {
        "order_id": "12345",
        "customer_email": "[email protected]"
      }
    }
  },
  "livemode": false,
  "pending_webhooks": 1
}

Key Fields:

  • data.object.id - PaymentIntent ID for your records
  • data.object.amount - Amount in smallest currency unit (cents for USD)
  • data.object.status - Will be "succeeded" for this event
  • data.object.customer - Customer ID (if payment linked to customer)
  • data.object.metadata - Custom metadata you attached during payment creation

Use Case: Fulfill orders, send confirmation emails, update order status to "paid"


Event: payment_intent.payment_failed

Description: Fires when a payment attempt fails. The PaymentIntent status becomes "requires_payment_method" and awaits a new payment method.

Payload Structure:

{
  "id": "evt_3MtwBwLkdIwHu7ix0SNd0F16",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067205,
  "type": "payment_intent.payment_failed",
  "data": {
    "object": {
      "id": "pi_3MtwBwLkdIwHu7ix0K7h6B0w",
      "object": "payment_intent",
      "amount": 5000,
      "currency": "usd",
      "status": "requires_payment_method",
      "last_payment_error": {
        "type": "card_error",
        "code": "card_declined",
        "message": "Your card was declined."
      },
      "customer": "cus_NffrFeUfNV2Hib",
      "metadata": {
        "order_id": "12346"
      }
    }
  },
  "livemode": false
}

Key Fields:

  • data.object.status - "requires_payment_method" after failure
  • data.object.last_payment_error - Details about why payment failed
  • data.object.last_payment_error.code - Error code (card_declined, insufficient_funds, etc.)

Use Case: Send payment failure notifications, prompt for new payment method, update order status


Event: customer.subscription.created

Description: Fires when a customer subscribes to a new plan. Use this to activate features, send welcome emails, and grant access.

Payload Structure:

{
  "id": "evt_1MzowsLkdIwHu7ix0P7p0Rgs",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067300,
  "type": "customer.subscription.created",
  "data": {
    "object": {
      "id": "sub_1MzowsLkdIwHu7ix0P7p0Rgt",
      "object": "subscription",
      "customer": "cus_NffrFeUfNV2Hib",
      "status": "active",
      "current_period_start": 1704067300,
      "current_period_end": 1706745700,
      "items": {
        "object": "list",
        "data": [
          {
            "id": "si_NffrFeUfNV2Hic",
            "object": "subscription_item",
            "price": {
              "id": "price_1MowQULkdIwHu7ixraBm864M",
              "object": "price",
              "unit_amount": 2999,
              "currency": "usd",
              "recurring": {
                "interval": "month"
              }
            }
          }
        ]
      },
      "metadata": {
        "user_id": "user_12345"
      }
    }
  },
  "livemode": false
}

Key Fields:

  • data.object.id - Subscription ID
  • data.object.customer - Customer ID
  • data.object.status - Subscription status ("active", "trialing", "past_due", etc.)
  • data.object.items.data[0].price - Pricing details including amount and interval
  • data.object.current_period_end - Unix timestamp when subscription renews

Use Case: Activate premium features, send welcome email, update user role


Event: customer.subscription.deleted

Description: Fires when a subscription is canceled or ends. Use this to revoke access and trigger cancellation workflows.

Payload Structure:

{
  "id": "evt_1MzowsLkdIwHu7ix0P7p0Rgu",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067400,
  "type": "customer.subscription.deleted",
  "data": {
    "object": {
      "id": "sub_1MzowsLkdIwHu7ix0P7p0Rgt",
      "object": "subscription",
      "customer": "cus_NffrFeUfNV2Hib",
      "status": "canceled",
      "canceled_at": 1704067400,
      "ended_at": 1704067400,
      "cancellation_details": {
        "reason": "cancellation_requested"
      }
    }
  },
  "livemode": false
}

Key Fields:

  • data.object.status - "canceled" when subscription ends
  • data.object.canceled_at - Timestamp of cancellation
  • data.object.cancellation_details.reason - Why subscription was canceled

Use Case: Revoke premium access, send cancellation confirmation, update database status


Event: charge.refunded

Description: Fires when a charge is refunded, either fully or partially. Use this to update inventory, notify customers, and adjust financial records.

Payload Structure:

{
  "id": "evt_3MtwBwLkdIwHu7ix0SNd0F17",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1704067500,
  "type": "charge.refunded",
  "data": {
    "object": {
      "id": "ch_3MtwBwLkdIwHu7ix0K7h6B0v",
      "object": "charge",
      "amount": 2000,
      "amount_refunded": 2000,
      "currency": "usd",
      "refunded": true,
      "refunds": {
        "object": "list",
        "data": [
          {
            "id": "re_3MtwBwLkdIwHu7ix0K7h6B0x",
            "object": "refund",
            "amount": 2000,
            "currency": "usd",
            "status": "succeeded",
            "reason": "requested_by_customer"
          }
        ]
      },
      "payment_intent": "pi_3MtwBwLkdIwHu7ix0K7h6B0v",
      "metadata": {
        "order_id": "12345"
      }
    }
  },
  "livemode": false
}

Key Fields:

  • data.object.amount_refunded - Total amount refunded in cents
  • data.object.refunded - Boolean indicating if fully refunded
  • data.object.refunds.data - Array of refund objects with details
  • data.object.refunds.data[0].reason - Refund reason

Use Case: Update order status, restore inventory, send refund confirmation


For a complete list of all Stripe event types, visit the Stripe Events API documentation. The payload structures follow consistent patterns, making it easier to handle multiple event types with similar code.

Webhook Signature Verification

Verifying webhook signatures is critical for security—it proves that webhook requests truly came from Stripe and haven't been tampered with. Without verification, malicious actors could send fake webhook requests to your endpoint, potentially triggering unauthorized actions like fulfilling fraudulent orders or activating fake subscriptions.

Why Stripe Signature Verification Matters

Security threats without verification:

  • Spoofing attacks - Attackers send fake webhook requests pretending to be Stripe
  • Replay attacks - Intercepted legitimate webhooks are resent repeatedly
  • Payload tampering - Modified webhook data triggers incorrect business logic

Stripe prevents these attacks through HMAC-SHA256 cryptographic signatures that include timestamps for replay protection. Your code must verify these signatures before processing any webhook event.

Stripe's Signature Method

Stripe uses HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256) to sign webhook payloads. Here's what you need to know:

Signature Header: Stripe-Signature

Header Format:

Stripe-Signature: t=1704067200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Components:

  • t= - Unix timestamp when Stripe sent the webhook
  • v1= - HMAC-SHA256 signature computed from timestamp + payload + secret

What's signed: The signature covers:

  1. Timestamp value (the t= value)
  2. Character . (literal period)
  3. Raw JSON request body (unmodified bytes)

Stripe may include multiple signature schemes (v1, v0) in the header for backward compatibility. Always verify against v1.

Step-by-Step Verification Process

Manual Verification (Educational)

While Stripe's SDKs handle this automatically, understanding the process helps debug issues:

  1. Extract signature components from the Stripe-Signature header
  2. Retrieve your webhook secret from environment variables
  3. Construct the signed payload by concatenating: {timestamp}.{raw_body}
  4. Compute expected signature using HMAC-SHA256 with your secret as the key
  5. Compare signatures using constant-time comparison (prevents timing attacks)
  6. Validate timestamp - Ensure it's within 5 minutes of current time (prevents replay attacks)

Automatic Verification (Recommended)

Stripe's official SDKs provide built-in verification that handles all complexity:

Code Examples

Node.js / Express with Stripe SDK

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

// CRITICAL: Use express.raw() for webhook endpoint to preserve raw body
app.use('/webhooks/stripe', express.raw({type: 'application/json'}));

app.post('/webhooks/stripe', (req, res) => {
  const sig = req.headers['stripe-signature'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;

  try {
    // Stripe SDK automatically verifies signature and validates timestamp
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Signature verified! Now process the event
  console.log(`Received verified event: ${event.type}`);

  // Handle different event types
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log(`Payment ${paymentIntent.id} succeeded`);
      // Your business logic here
      break;

    case 'customer.subscription.created':
      const subscription = event.data.object;
      console.log(`Subscription ${subscription.id} created`);
      // Your business logic here
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Return 200 to acknowledge receipt
  res.status(200).json({received: true});
});

const PORT = process.env.PORT || 4242;
app.listen(PORT, () => console.log(`Webhook server running on port ${PORT}`));

Critical Implementation Note: The express.raw() middleware is required for the /webhooks/stripe route. Express's default JSON parser modifies the request body, which invalidates signatures. Using express.raw() preserves the exact bytes Stripe sent, allowing signature verification to succeed.


Python / Flask with Stripe SDK

import stripe
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()
    sig_header = request.headers.get('Stripe-Signature')

    try:
        # Stripe SDK verifies signature and timestamp automatically
        event = stripe.Webhook.construct_event(
            payload, sig_header, webhook_secret
        )
    except ValueError as e:
        # Invalid payload
        print(f'Invalid payload: {e}')
        return jsonify({'error': 'Invalid payload'}), 400
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
        print(f'Signature verification failed: {e}')
        return jsonify({'error': 'Invalid signature'}), 400

    # Signature verified! Process the event
    print(f'Received verified event: {event["type"]}')

    # Handle different event types
    if event['type'] == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        print(f'Payment {payment_intent["id"]} succeeded')
        # Your business logic here

    elif event['type'] == 'customer.subscription.created':
        subscription = event['data']['object']
        print(f'Subscription {subscription["id"]} created')
        # Your business logic here

    else:
        print(f'Unhandled event type: {event["type"]}')

    # Return 200 to acknowledge receipt
    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=4242)

PHP with Stripe SDK

<?php
require 'vendor/autoload.php';

// Set Stripe API key
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

// Get webhook secret from environment
$webhookSecret = getenv('STRIPE_WEBHOOK_SECRET');

// Get raw POST body and signature header
$payload = @file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];

try {
    // Stripe SDK verifies signature and timestamp automatically
    $event = \Stripe\Webhook::constructEvent(
        $payload,
        $sigHeader,
        $webhookSecret
    );
} catch (\UnexpectedValueException $e) {
    // Invalid payload
    http_response_code(400);
    echo json_encode(['error' => 'Invalid payload']);
    exit();
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    // Invalid signature
    http_response_code(400);
    echo json_encode(['error' => 'Invalid signature']);
    exit();
}

// Signature verified! Process the event
error_log("Received verified event: {$event->type}");

// Handle different event types
switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object;
        error_log("Payment {$paymentIntent->id} succeeded");
        // Your business logic here
        break;

    case 'customer.subscription.created':
        $subscription = $event->data->object;
        error_log("Subscription {$subscription->id} created");
        // Your business logic here
        break;

    default:
        error_log("Unhandled event type: {$event->type}");
}

// Return 200 to acknowledge receipt
http_response_code(200);
echo json_encode(['received' => true]);
?>

Common Verification Errors

❌ Error: "No signatures found matching the expected signature"

  • Cause: Using wrong webhook secret (test vs. live mode mismatch)
  • Fix: Verify you're using the correct secret from Stripe Dashboard for your environment

❌ Error: "Timestamp outside the tolerance zone"

  • Cause: Clock skew between your server and Stripe's servers (>5 minutes)
  • Fix: Ensure your server's system time is synchronized via NTP

❌ Error: "Signature verification failed"

  • Cause: Request body was modified before verification (most common!)
  • Fix: Use raw body parser (express.raw, request.get_data(), file_get_contents)

❌ Error: JSON parsing before verification

  • Cause: Body parsed into JavaScript object before signature check
  • Fix: Verify signature with raw bytes first, then parse JSON

Timestamp Validation

Stripe includes a timestamp (t=) in webhook signatures to prevent replay attacks. The Stripe SDK automatically validates that timestamps are within 5 minutes of the current time. If a webhook is older than 5 minutes (possibly intercepted and replayed by an attacker), verification will fail.

This timestamp tolerance means your server's clock must be reasonably accurate. Use NTP (Network Time Protocol) to keep server time synchronized.

Security Checklist

  • ✅ Always verify signatures using Stripe SDK (never skip verification!)
  • ✅ Use express.raw() or equivalent to preserve raw request body
  • ✅ Store webhook secrets in environment variables (never hardcode)
  • ✅ Use constant-time comparison if implementing manual verification
  • ✅ Validate timestamp within 5-minute tolerance window
  • ✅ Return 400 status code for verification failures
  • ✅ Log verification failures for security monitoring
  • ✅ Use separate secrets for test and live mode

With signature verification properly implemented, you can confidently process Stripe webhook events knowing they're authentic and haven't been tampered with.

Testing Stripe Webhooks

Testing webhooks during development presents unique challenges—Stripe's servers need to reach your endpoint, but your local development environment isn't publicly accessible. Fortunately, Stripe provides excellent tools for webhook testing, and our Webhook Payload Generator offers an alternative testing approach.

Local Development Challenges

The problem:

  • Stripe can't reach http://localhost:4242/webhooks/stripe
  • You need a publicly accessible HTTPS URL
  • SSL certificates are required for production webhooks
  • You want to test without deploying to staging servers

Solution 1: Stripe CLI (Official Method)

The Stripe CLI is Stripe's official command-line tool that forwards live webhook events from your Stripe account directly to your local development server.

Installation

macOS:

brew install stripe/stripe-cli/stripe

Windows: Download from github.com/stripe/stripe-cli/releases

Linux:

# Debian/Ubuntu
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_amd64.deb
sudo dpkg -i stripe_linux_amd64.deb

Login to Your Stripe Account

stripe login

This opens a browser window where you authorize the CLI to access your Stripe account. The CLI receives API keys and stores them locally.

Forward Webhooks to Localhost

# Start your local server first (port 4242)
node server.js

# In a separate terminal, forward webhooks
stripe listen --forward-to localhost:4242/webhooks/stripe

Output:

> Ready! Your webhook signing secret is whsec_abc123... (^C to quit)

The CLI displays a temporary webhook signing secret starting with whsec_. Use this secret in your local environment variables while testing:

# In your .env file or export temporarily
export STRIPE_WEBHOOK_SECRET=whsec_abc123temporary456secret789

Trigger Test Events

While stripe listen is running, trigger specific events:

# Trigger a successful payment
stripe trigger payment_intent.succeeded

# Trigger a failed payment
stripe trigger payment_intent.payment_failed

# Trigger subscription creation
stripe trigger customer.subscription.created

# Trigger subscription deletion
stripe trigger customer.subscription.deleted

The Stripe CLI generates realistic test events and forwards them to your local endpoint. Your server receives them exactly as it would in production.

View Event Logs

The CLI shows real-time logs of forwarded events:

2025-01-24 12:30:45  --> payment_intent.succeeded [evt_test_abc123]
2025-01-24 12:30:45  <-- [200] POST http://localhost:4242/webhooks/stripe [evt_test_abc123]

Check your application logs to verify your handler processed the event correctly.


Solution 2: ngrok (Alternative Tunneling)

If you prefer not to use the Stripe CLI, ngrok creates a secure tunnel to your localhost, providing a public HTTPS URL.

# Install ngrok
brew install ngrok  # macOS
# or download from ngrok.com

# Start your local server (port 4242)
node server.js

# Create public tunnel to localhost
ngrok http 4242

ngrok output:

Forwarding  https://abc123def456.ngrok.io -> http://localhost:4242

Use the ngrok HTTPS URL in your Stripe Dashboard webhook settings:

https://abc123def456.ngrok.io/webhooks/stripe

Now Stripe sends real events to your local server via the ngrok tunnel. Remember to use your actual webhook secret from the Stripe Dashboard (not a temporary one).

ngrok limitations:

  • Free tier URLs change every time you restart ngrok
  • You must update the webhook URL in Stripe Dashboard each time
  • Less convenient than Stripe CLI for frequent testing

Solution 3: Webhook Payload Generator (No Tunneling Required)

For testing without external tools, use our Webhook Payload Generator to create realistic Stripe webhook payloads with valid signatures.

How to test with the generator:

  1. Visit Webhook Payload Generator
  2. Select "Stripe" from the provider dropdown
  3. Choose an event type (e.g., payment_intent.succeeded)
  4. Customize payload fields:
    • Amount: 2000 (for $20.00)
    • Currency: usd
    • Customer ID: cus_test123
    • Metadata: order_id, customer_email, etc.
  5. Enter your webhook secret (the tool generates valid signatures)
  6. Click "Generate Payload"
  7. Copy the generated JSON payload with signature header
  8. Send to your local endpoint using curl:
curl -X POST http://localhost:4242/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1704067200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd" \
  -d @generated-payload.json

Benefits of the Webhook Payload Generator:

  • ✅ No tunneling or CLI tools required
  • ✅ Test signature verification logic independently
  • ✅ Customize any payload field for edge case testing
  • ✅ Generate multiple event types quickly
  • ✅ Test error handling with malformed payloads
  • ✅ Works offline (no internet connection needed)

Use cases:

  • Unit testing webhook handlers
  • Testing edge cases (unusual amounts, missing fields)
  • Verifying signature verification logic
  • Load testing with multiple events
  • CI/CD pipeline integration

Testing Checklist

Before deploying to production, verify:

  • Signature verification passes - Endpoint correctly verifies HMAC-SHA256 signatures
  • Returns 200 within 5 seconds - Responds quickly to prevent timeouts
  • Handles all subscribed event types - No unhandled event types cause errors
  • Implements idempotency - Duplicate events don't cause duplicate actions
  • Processes asynchronously - Long-running tasks don't block the response
  • Logs events properly - All events logged with event IDs for debugging
  • Handles malformed payloads gracefully - Invalid JSON or missing fields don't crash endpoint
  • Validates event livemode flag - Test events don't affect production data
  • Error handling works - Try/catch blocks prevent unhandled exceptions
  • Webhook secret is correct - Using the right secret for test vs. live mode

Stripe's Built-in Testing Features

The Stripe Dashboard provides additional testing capabilities:

Send Test Webhook

  1. Go to Developers > Webhooks
  2. Click on your webhook endpoint
  3. Click "Send test webhook"
  4. Select an event type
  5. Click "Send test webhook"

This sends a sample event directly from Stripe's servers to your endpoint—useful for verifying your endpoint is publicly accessible and responds correctly.

View Webhook Logs The Dashboard shows detailed logs for every webhook delivery attempt:

  • Request body sent by Stripe
  • Response code your endpoint returned
  • Response time
  • Any error messages
  • Retry attempts

Use these logs to debug delivery failures, signature verification issues, or timeout problems.

With thorough testing using these tools, you'll deploy production-ready Stripe webhook handlers with confidence.

Implementation Example: Production-Ready Stripe Webhook Endpoint

Building a robust webhook endpoint requires more than just receiving POST requests—you need fast responses, asynchronous processing, idempotency, error handling, and detailed logging. This section shows a complete production-ready implementation.

Requirements for Production Webhooks

Stripe has specific expectations for webhook endpoints:

  • Respond within ~5 seconds - Return 200 status quickly to prevent timeouts
  • Return 2xx status code - Any non-2xx response triggers retries
  • Process asynchronously - Queue long-running tasks, don't block the response
  • Handle retries gracefully - Implement idempotency to safely process duplicate events
  • Log comprehensively - Track all events, successes, and failures for debugging

Complete Node.js Implementation with Queue

This example uses Express for the web server, Stripe SDK for verification, and Bull for queue-based async processing:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Queue = require('bull');
const { createClient } = require('redis');

const app = express();
const webhookQueue = new Queue('stripe-webhooks', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: process.env.REDIS_PORT || 6379
  }
});

// Database helper (pseudocode - adapt to your database)
const db = require('./database');

// ========================================
// WEBHOOK ENDPOINT
// ========================================

// CRITICAL: Use express.raw() to preserve raw body for signature verification
app.use('/webhooks/stripe', express.raw({type: 'application/json'}));

app.post('/webhooks/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;

  try {
    // 1. Verify signature using Stripe SDK
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error('⚠️ Webhook signature verification failed:', err.message);
    return res.status(400).json({
      error: 'Invalid signature',
      message: err.message
    });
  }

  const eventId = event.id;
  const eventType = event.type;

  console.log(`✅ Signature verified: ${eventType} [${eventId}]`);

  try {
    // 2. Check for duplicate (idempotency)
    const exists = await db.webhookEvents.findUnique({
      where: { eventId: eventId }
    });

    if (exists) {
      console.log(`⏭️ Event ${eventId} already processed, skipping`);
      return res.status(200).json({
        received: true,
        duplicate: true
      });
    }

    // 3. Store event as "processing" to prevent duplicates
    await db.webhookEvents.create({
      data: {
        eventId: eventId,
        eventType: eventType,
        status: 'processing',
        receivedAt: new Date(),
        payload: event
      }
    });

    // 4. Queue for async processing
    await webhookQueue.add({
      eventId,
      eventType,
      event
    }, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000
      }
    });

    // 5. Return 200 immediately (before processing)
    res.status(200).json({
      received: true,
      eventId: eventId
    });

    console.log(`📨 Queued ${eventType} event: ${eventId}`);

  } catch (error) {
    console.error('❌ Webhook processing error:', error);

    // Still return 200 to prevent Stripe retries for our internal errors
    // Log the error and investigate manually
    res.status(200).json({
      received: true,
      error: true
    });
  }
});

// ========================================
// QUEUE PROCESSOR
// ========================================

webhookQueue.process(async (job) => {
  const { eventId, eventType, event } = job.data;

  console.log(`🔄 Processing ${eventType} [${eventId}]...`);

  try {
    // Handle different event types
    switch (eventType) {
      case 'payment_intent.succeeded':
        await handlePaymentSucceeded(event);
        break;

      case 'payment_intent.payment_failed':
        await handlePaymentFailed(event);
        break;

      case 'customer.subscription.created':
        await handleSubscriptionCreated(event);
        break;

      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event);
        break;

      case 'charge.refunded':
        await handleChargeRefunded(event);
        break;

      default:
        console.log(`ℹ️ Unhandled event type: ${eventType}`);
    }

    // Mark as completed
    await db.webhookEvents.update({
      where: { eventId: eventId },
      data: {
        status: 'completed',
        completedAt: new Date()
      }
    });

    console.log(`✅ Completed ${eventType} [${eventId}]`);

  } catch (error) {
    console.error(`❌ Failed to process ${eventId}:`, error);

    // Mark as failed
    await db.webhookEvents.update({
      where: { eventId: eventId },
      data: {
        status: 'failed',
        error: error.message,
        failedAt: new Date()
      }
    });

    throw error; // Will trigger queue retry
  }
});

// ========================================
// BUSINESS LOGIC HANDLERS
// ========================================

async function handlePaymentSucceeded(event) {
  const paymentIntent = event.data.object;
  const orderId = paymentIntent.metadata.order_id;

  console.log(`💰 Payment succeeded: ${paymentIntent.id}`);

  // Update order status
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: 'paid',
      stripePaymentIntentId: paymentIntent.id,
      paidAt: new Date()
    }
  });

  // Send confirmation email
  await sendEmail({
    to: paymentIntent.metadata.customer_email,
    subject: 'Payment Successful - Order Confirmed',
    template: 'payment-confirmation',
    data: {
      orderId: orderId,
      amount: paymentIntent.amount / 100,
      currency: paymentIntent.currency.toUpperCase()
    }
  });

  // Trigger fulfillment workflow
  await triggerFulfillment(orderId);

  console.log(`✅ Order ${orderId} marked as paid and fulfillment triggered`);
}

async function handlePaymentFailed(event) {
  const paymentIntent = event.data.object;
  const orderId = paymentIntent.metadata.order_id;
  const errorMessage = paymentIntent.last_payment_error?.message;

  console.log(`❌ Payment failed: ${paymentIntent.id} - ${errorMessage}`);

  // Update order status
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: 'payment_failed',
      paymentErrorMessage: errorMessage
    }
  });

  // Send failure notification
  await sendEmail({
    to: paymentIntent.metadata.customer_email,
    subject: 'Payment Failed - Action Required',
    template: 'payment-failed',
    data: {
      orderId: orderId,
      errorMessage: errorMessage,
      retryUrl: `https://yourdomain.com/orders/${orderId}/retry-payment`
    }
  });

  console.log(`⚠️ Order ${orderId} marked as payment failed, customer notified`);
}

async function handleSubscriptionCreated(event) {
  const subscription = event.data.object;
  const customerId = subscription.customer;
  const userId = subscription.metadata.user_id;

  console.log(`🎉 Subscription created: ${subscription.id}`);

  // Update user account
  await db.users.update({
    where: { id: userId },
    data: {
      subscriptionStatus: 'active',
      subscriptionId: subscription.id,
      stripeCustomerId: customerId,
      subscriptionStartDate: new Date(subscription.current_period_start * 1000),
      subscriptionEndDate: new Date(subscription.current_period_end * 1000)
    }
  });

  // Grant premium access
  await db.userRoles.create({
    data: {
      userId: userId,
      role: 'premium'
    }
  });

  // Send welcome email
  await sendEmail({
    to: subscription.metadata.user_email,
    subject: 'Welcome to Premium!',
    template: 'subscription-welcome',
    data: {
      subscriptionId: subscription.id
    }
  });

  console.log(`✅ User ${userId} activated with premium subscription`);
}

async function handleSubscriptionDeleted(event) {
  const subscription = event.data.object;
  const userId = subscription.metadata.user_id;

  console.log(`😢 Subscription deleted: ${subscription.id}`);

  // Update user account
  await db.users.update({
    where: { id: userId },
    data: {
      subscriptionStatus: 'canceled',
      subscriptionEndDate: new Date()
    }
  });

  // Revoke premium access
  await db.userRoles.deleteMany({
    where: {
      userId: userId,
      role: 'premium'
    }
  });

  // Send cancellation confirmation
  await sendEmail({
    to: subscription.metadata.user_email,
    subject: 'Subscription Canceled',
    template: 'subscription-canceled',
    data: {
      subscriptionId: subscription.id,
      reactivateUrl: 'https://yourdomain.com/subscribe'
    }
  });

  console.log(`✅ User ${userId} premium access revoked`);
}

async function handleChargeRefunded(event) {
  const charge = event.data.object;
  const orderId = charge.metadata.order_id;
  const refundAmount = charge.amount_refunded;

  console.log(`💸 Charge refunded: ${charge.id} - $${refundAmount / 100}`);

  // Update order status
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: charge.refunded ? 'fully_refunded' : 'partially_refunded',
      refundedAmount: refundAmount,
      refundedAt: new Date()
    }
  });

  // Restore inventory if fully refunded
  if (charge.refunded) {
    await restoreInventory(orderId);
  }

  // Send refund confirmation
  await sendEmail({
    to: charge.metadata.customer_email,
    subject: 'Refund Processed',
    template: 'refund-confirmation',
    data: {
      orderId: orderId,
      refundAmount: refundAmount / 100,
      currency: charge.currency.toUpperCase()
    }
  });

  console.log(`✅ Order ${orderId} refund processed`);
}

// ========================================
// HELPER FUNCTIONS
// ========================================

async function sendEmail(emailData) {
  // Implement your email service (SendGrid, SES, etc.)
  console.log(`📧 Sending email to ${emailData.to}: ${emailData.subject}`);
  // await emailService.send(emailData);
}

async function triggerFulfillment(orderId) {
  // Trigger order fulfillment workflow
  console.log(`📦 Triggering fulfillment for order ${orderId}`);
  // await fulfillmentService.process(orderId);
}

async function restoreInventory(orderId) {
  // Restore product inventory after refund
  console.log(`📦 Restoring inventory for order ${orderId}`);
  // await inventoryService.restore(orderId);
}

// ========================================
// ERROR HANDLING & MONITORING
// ========================================

// Queue error handling
webhookQueue.on('failed', (job, err) => {
  console.error(`❌ Job ${job.id} failed after ${job.attemptsMade} attempts:`, err);
  // Send alert to monitoring service
});

webhookQueue.on('completed', (job) => {
  console.log(`✅ Job ${job.id} completed successfully`);
});

// ========================================
// SERVER STARTUP
// ========================================

const PORT = process.env.PORT || 4242;
app.listen(PORT, () => {
  console.log(`🚀 Stripe webhook server running on port ${PORT}`);
  console.log(`📬 Webhook endpoint: http://localhost:${PORT}/webhooks/stripe`);
});

Key Implementation Details

1. Raw Body Parsing Using express.raw() preserves the exact bytes Stripe sent, which is critical for signature verification. The body remains as a Buffer until after verification.

2. Signature Verification First Never process webhook data before verifying the signature. If verification fails, immediately return 400 and log the failure.

3. Idempotency Check Before processing, check if the event ID already exists in your database. Stripe may retry webhooks, so you must handle duplicates gracefully.

4. Immediate 200 Response Return success status before starting any time-consuming work. This prevents timeouts and tells Stripe the webhook was received successfully.

5. Queue-Based Processing Use a queue system (Bull, BullMQ, RabbitMQ, AWS SQS) to process webhooks asynchronously. This keeps responses fast while handling complex business logic in the background.

6. Comprehensive Error Handling Wrap everything in try/catch blocks. Even if processing fails, return 200 to prevent Stripe retries for your internal errors. Log failures for manual investigation.

7. Detailed Logging Log every step with clear indicators (✅, ❌, ⚠️, 📨) for easy debugging. Include event IDs in all log messages for traceability.

8. Event Status Tracking Store webhook events in your database with status tracking (processing, completed, failed). This provides visibility into webhook processing and helps debug issues.

With this production-ready implementation pattern, you'll build reliable Stripe webhook handlers that scale with your business.

Best Practices for Stripe Webhooks

Following these best practices ensures secure, reliable, and performant webhook implementations that scale with your business.

Security Best Practices

✅ Always verify signatures Never skip signature verification, even in development. Implement verification on every webhook endpoint using Stripe's official SDKs.

✅ Use HTTPS endpoints only Production webhooks must use HTTPS. Stripe enforces this requirement to prevent man-in-the-middle attacks intercepting payment data.

✅ Store secrets in environment variables Never hardcode webhook secrets in source code. Use environment variables and keep secrets out of version control.

# .env file
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
STRIPE_SECRET_KEY=sk_live_your_key_here

✅ Validate timestamp to prevent replay attacks Stripe's SDK automatically validates that timestamps are within 5 minutes. If implementing manual verification, always check the timestamp.

✅ Use separate secrets for test and live mode Configure different webhook endpoints with different secrets for test and production. This prevents test events from affecting live data.

✅ Rate limit webhook endpoints While Stripe sends legitimate traffic, implement rate limiting to protect against potential abuse or misconfiguration.

✅ Whitelist Stripe IP addresses (optional) For extra security, configure your firewall to only accept webhook requests from Stripe's published IP ranges. However, note that these IPs can change, so signature verification is your primary security mechanism.


Performance Best Practices

✅ Respond within 5 seconds Stripe expects a 2xx response within approximately 5 seconds. Any slower and the webhook times out, triggering retries.

✅ Return 200 immediately, process async Use this pattern:

  1. Verify signature
  2. Store event data
  3. Return 200
  4. Process asynchronously via queue
// Good: Fast response
app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...);
  await queue.add(event);
  res.status(200).json({received: true}); // Fast!

  // Processing happens in background
});

// Bad: Slow response
app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...);
  await updateDatabase(...);     // Slow
  await sendEmail(...);           // Slow
  await callExternalAPI(...);     // Slow
  res.status(200).json({received: true}); // Timeout!
});

✅ Use queue systems for processing Implement queues (Bull, RabbitMQ, AWS SQS, Redis) to decouple receiving webhooks from processing them. This prevents timeouts and enables horizontal scaling.

✅ Implement exponential backoff for external calls When calling external APIs from webhook handlers, use exponential backoff and retry logic to handle transient failures.

✅ Monitor webhook processing times Track how long webhook processing takes. If times approach 5 seconds, investigate bottlenecks and optimize database queries or external API calls.


Reliability Best Practices

✅ Implement idempotency Store event IDs in your database before processing. Check for duplicates to prevent processing the same event multiple times.

// Check if event already processed
const exists = await db.webhookEvents.findUnique({
  where: { eventId: event.id }
});

if (exists) {
  return res.status(200).json({received: true, duplicate: true});
}

// Mark as processing
await db.webhookEvents.create({
  data: { eventId: event.id, status: 'processing' }
});

✅ Handle duplicate webhooks gracefully Stripe may retry webhook delivery if your endpoint fails or times out. Design handlers to safely process duplicates without side effects.

✅ Implement retry logic for failed processing If webhook processing fails (database down, external API timeout), use exponential backoff retries. Queue systems like Bull provide this automatically.

✅ Don't rely solely on webhooks Implement reconciliation jobs that periodically check Stripe's API for events you might have missed. Webhooks are reliable but not infallible—networks fail, services restart.

// Daily reconciliation job
async function reconcilePayments() {
  const yesterday = Math.floor(Date.now() / 1000) - 86400;
  const events = await stripe.events.list({
    type: 'payment_intent.succeeded',
    created: { gte: yesterday }
  });

  for (const event of events.data) {
    // Check if we processed this event
    const exists = await db.webhookEvents.findUnique({
      where: { eventId: event.id }
    });

    if (!exists) {
      console.warn(`Missed event ${event.id}, processing now`);
      await processEvent(event);
    }
  }
}

✅ Log all webhook events comprehensively Log event IDs, types, processing status, and any errors. These logs are invaluable for debugging production issues.


Monitoring Best Practices

✅ Track webhook delivery success rate Monitor the percentage of webhooks that process successfully. Alert on anomalies indicating potential issues.

✅ Alert on signature verification failures Multiple signature verification failures may indicate an attack attempt or misconfiguration. Set up alerts for this scenario.

✅ Monitor processing queue depth If your queue grows significantly, it indicates processing can't keep up with incoming webhooks. Alert and investigate bottlenecks.

✅ Log event IDs for traceability Include event IDs in all logs. This allows you to trace a specific webhook from Stripe's Dashboard through your entire processing pipeline.

✅ Set up health checks Implement a health check endpoint that verifies your webhook processor is running and can reach critical dependencies (database, queue, external APIs).

✅ Use Stripe Dashboard for debugging The Stripe Dashboard shows webhook delivery attempts, response codes, response times, and retry history. Use these logs to troubleshoot delivery issues.


Stripe-Specific Best Practices

✅ Check the livemode flag Every Stripe event includes a livemode boolean. Verify this matches your environment to prevent test events from affecting production data.

if (event.livemode !== (process.env.NODE_ENV === 'production')) {
  console.error('Event mode mismatch!');
  return res.status(400).json({error: 'Mode mismatch'});
}

✅ Handle events in any order Stripe doesn't guarantee event order. Design handlers to work regardless of event sequence. Use timestamps to determine event chronology if needed.

✅ Use metadata for context Attach metadata to Stripe objects (PaymentIntents, Subscriptions) to include your internal IDs (order IDs, user IDs). This makes webhook processing easier.

// When creating PaymentIntent
const paymentIntent = await stripe.paymentIntents.create({
  amount: 2000,
  currency: 'usd',
  metadata: {
    order_id: '12345',
    user_id: 'user_789',
    source: 'web_checkout'
  }
});

// In webhook handler
const orderId = event.data.object.metadata.order_id;

✅ Subscribe only to needed events Don't subscribe to all events. Select only the events your application handles to reduce processing overhead and simplify debugging.

✅ Test with Stripe's test mode Always test webhook implementations in test mode before deploying to production. Use the Stripe CLI to simulate events locally.

✅ Keep Stripe SDK updated Regularly update the Stripe SDK to get security patches, new features, and bug fixes. Check the Stripe changelog for breaking changes.

By following these best practices, you'll build production-grade Stripe webhook integrations that are secure, reliable, and performant at scale.

Common Issues & Troubleshooting

Even with careful implementation, you may encounter webhook issues. This section covers the most common problems and their solutions.


Issue 1: Signature Verification Failing

Symptoms:

  • 400 errors in Stripe Dashboard webhook logs
  • "Invalid signature" errors in your application logs
  • Webhooks appear in Dashboard but never processed successfully

Causes & Solutions:

❌ Using wrong webhook secret The most common cause. Verify you're using the correct secret for your environment.

Solution:

  • Check Stripe Dashboard → Developers → Webhooks → Click your endpoint → Reveal signing secret
  • Ensure STRIPE_WEBHOOK_SECRET environment variable matches exactly
  • Confirm you're not mixing test and live mode secrets

❌ Parsing JSON before verification Express or other frameworks automatically parse JSON, modifying the raw body and invalidating signatures.

Solution: Use express.raw() middleware for your webhook route:

app.use('/webhooks/stripe', express.raw({type: 'application/json'}));

❌ Incorrect algorithm or header name Using the wrong signature algorithm or reading the wrong header.

Solution:

  • Use Stripe's official SDK: stripe.webhooks.constructEvent()
  • Never implement manual verification unless necessary
  • Verify you're reading the stripe-signature header (lowercase with hyphens)

❌ Encoding issues Incorrect character encoding when reading the request body.

Solution: Ensure you're using the raw bytes without encoding transformations. The body should be a Buffer, not a UTF-8 string.

❌ Clock skew between servers Stripe validates timestamps are within 5 minutes. If your server clock is significantly off, verification fails.

Solution: Ensure your server uses NTP to keep accurate time:

# Linux - check time synchronization
timedatectl status

# macOS - enable automatic time
sudo systemsetup -setusingnetworktime on

Issue 2: Webhook Timeouts

Symptoms:

  • Stripe Dashboard shows "Timeout" or "No response" errors
  • Webhooks marked as failed with multiple retry attempts
  • Your logs show webhook received but Stripe logs show timeout

Causes & Solutions:

❌ Slow database queries blocking response Executing expensive database operations before returning 200.

Solution: Return 200 immediately, then process asynchronously:

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...);

  // Store for async processing
  await queue.add(event);

  // Return immediately (< 1 second)
  res.status(200).json({received: true});
});

❌ External API calls timing out Calling third-party APIs (email services, fulfillment systems) before responding.

Solution: Move all external calls to background jobs:

// Bad
await sendgrid.sendEmail(...);  // 2-5 seconds
res.status(200).json({received: true});

// Good
res.status(200).json({received: true});
queue.add('send-email', {...});  // Process later

❌ Complex business logic taking too long Processing orders, calculating analytics, or updating multiple systems synchronously.

Solution: Use a queue system to process webhooks asynchronously. Your endpoint should only verify, store, and respond—all processing happens in background workers.

❌ Insufficient server resources Server overloaded with requests, causing slow responses.

Solution:

  • Scale horizontally (add more servers behind load balancer)
  • Monitor CPU and memory usage
  • Optimize database queries and indexes
  • Consider serverless functions that auto-scale

Issue 3: Duplicate Event Processing

Symptoms:

  • Same webhook processed multiple times
  • Duplicate orders fulfilled
  • Multiple confirmation emails sent
  • Database constraints violated due to duplicate inserts

Causes & Solutions:

❌ No idempotency check Not tracking which events have been processed.

Solution: Store event IDs before processing and check for duplicates:

// Check if already processed
const exists = await db.webhookEvents.findUnique({
  where: { eventId: event.id }
});

if (exists) {
  console.log('Event already processed, skipping');
  return res.status(200).json({received: true, duplicate: true});
}

// Mark as processing
await db.webhookEvents.create({
  data: { eventId: event.id, status: 'processing' }
});

// Now safe to process

❌ Network retries from Stripe If your endpoint times out or returns non-2xx, Stripe automatically retries webhook delivery.

Solution:

  • Return 200 quickly to prevent retries
  • Implement idempotent handlers that safely process duplicates
  • Design operations to be naturally idempotent (e.g., "set status to X" instead of "increment count")

❌ Race conditions in async processing Multiple workers processing the same event simultaneously.

Solution: Use database transactions and unique constraints:

// Create event record with unique constraint on eventId
try {
  await db.webhookEvents.create({
    data: { eventId: event.id, status: 'processing' }
  });
  // If this succeeds, we're the first to process this event
} catch (error) {
  if (error.code === 'P2002') { // Prisma unique constraint violation
    console.log('Another worker already processing this event');
    return;
  }
  throw error;
}

Issue 4: Missing Webhooks

Symptoms:

  • Expected webhooks never arrive
  • Events visible in Stripe Dashboard but not received by your endpoint
  • Delivery logs show "Could not connect" or similar errors

Causes & Solutions:

❌ Firewall blocking requests Server firewall or cloud security group blocking Stripe's IP addresses.

Solution:

  • Configure firewall to allow HTTPS traffic (port 443)
  • Whitelist Stripe's webhook IPs: https://stripe.com/files/ips/ips_webhooks.txt
  • Check cloud provider security groups (AWS, GCP, Azure)
  • Test with: curl -X POST https://your-domain.com/webhooks/stripe

❌ Incorrect webhook URL Typo in webhook URL or pointing to wrong environment.

Solution:

  • Verify URL in Stripe Dashboard exactly matches your endpoint
  • Check for typos (webhooks vs webhook, stripe vs strips)
  • Ensure protocol is HTTPS (not HTTP) for production
  • Test URL accessibility: curl -I https://your-domain.com/webhooks/stripe

❌ SSL certificate issues Invalid, expired, or self-signed SSL certificate.

Solution:

  • Verify certificate is valid and not expired
  • Use a trusted CA (Let's Encrypt is free)
  • Test with: curl https://your-domain.com/webhooks/stripe
  • Check certificate: openssl s_client -connect your-domain.com:443

❌ DNS resolution problems Domain doesn't resolve or points to wrong IP.

Solution:

  • Verify DNS records: nslookup your-domain.com
  • Wait for DNS propagation after changes (up to 48 hours)
  • Test from multiple locations

❌ Webhook endpoint not running Server crashed, deployment failed, or endpoint path changed.

Solution:

  • Check server logs for crashes
  • Verify process is running: ps aux | grep node
  • Test endpoint manually: curl -X POST https://your-domain.com/webhooks/stripe
  • Implement health checks and monitoring

Issue 5: Events Processing Out of Order

Symptoms:

  • Subscription updated events arrive before created events
  • Race conditions causing incorrect application state
  • Data inconsistencies due to event ordering assumptions

Causes & Solutions:

❌ Assuming Stripe guarantees event order Stripe makes no guarantees about event delivery order.

Solution: Design handlers to work regardless of order:

// Bad: Assumes customer.created arrives first
case 'customer.updated':
  const customer = await db.customers.findUnique(...);
  // customer might not exist yet!

// Good: Handle missing customer gracefully
case 'customer.updated':
  const customer = await db.customers.findUnique(...);
  if (!customer) {
    console.warn('Customer not found, will process when customer.created arrives');
    return;
  }
  // Or: upsert customer (create if doesn't exist)
  await db.customers.upsert({
    where: { stripeCustomerId: event.data.object.id },
    create: { ...event.data.object },
    update: { ...event.data.object }
  });

❌ Race conditions in async processing Multiple webhook workers processing events simultaneously in wrong order.

Solution:

  • Use timestamps from Stripe events to determine true chronology
  • Implement version numbers or "last updated" timestamps
  • Design operations to be commutative (A then B = B then A)

Debugging Checklist

When troubleshooting webhook issues:

  • Check Stripe Dashboard webhook logs - See delivery attempts, response codes, and error messages
  • Verify endpoint is publicly accessible - Test with curl from external machine
  • Test signature verification - Use our Webhook Payload Generator with known-good payloads
  • Review application logs - Look for errors, stack traces, or missing log entries
  • Verify SSL certificate is valid - Use SSL checker tools
  • Check firewall and security groups - Ensure Stripe IPs aren't blocked
  • Test with Stripe CLI - Forward events to localhost to isolate issues
  • Verify webhook secret matches - Compare with Dashboard value
  • Check server resource usage - CPU, memory, disk space
  • Review recent code changes - Correlation with when issues started

For additional help, check the Stripe Community or review the Stripe API Status Page for service incidents.

Frequently Asked Questions

Q: How often does Stripe send webhooks?

A: Stripe sends webhooks immediately when events occur—typically within milliseconds to a few seconds of the triggering action. There's no polling interval or delay. If delivery fails, Stripe retries with exponential backoff: first retry after a few seconds, then progressively longer intervals (minutes, hours), continuing for up to 3 days.


Q: Can I receive webhooks for past events?

A: No, Stripe only sends webhooks for events as they occur going forward from when you configure the webhook endpoint. You cannot retroactively receive webhooks for historical events. However, you can query the Events API to retrieve events from the past 30 days programmatically if you need to backfill data or reconcile missed events.


Q: What happens if my endpoint is down?

A: Stripe automatically retries failed webhook deliveries using exponential backoff over approximately 3 days. The retry schedule increases intervals between attempts: seconds, minutes, hours, until reaching the maximum retry period. After 3 days, Stripe stops retrying and marks the webhook as failed. You can view all delivery attempts in the Stripe Dashboard (Developers → Webhooks → Events) and manually retry specific events if needed. Implement reconciliation jobs that periodically check Stripe's API to catch any events you missed during downtime.


Q: Do I need different endpoints for test and production?

A: Yes, strongly recommended. Use separate webhook URLs with different signing secrets for test mode and live mode. This prevents test events (created with test API keys) from affecting production data and allows you to safely test changes in your test environment before deploying to production. Configure two separate endpoints in the Stripe Dashboard—one for test mode and one for live mode.


Q: How do I handle webhook ordering?

A: Stripe does not guarantee that webhooks will arrive in chronological order. Multiple events may be sent simultaneously, and network conditions can cause later events to arrive before earlier ones. Best practice: Design your webhook handlers to be order-independent by using the timestamp fields within event data to determine actual chronology. Implement idempotent operations and use database upserts that work correctly regardless of event order. Never assume customer.subscription.created arrives before customer.subscription.updated.


Q: Can I filter which events I receive?

A: Yes, when configuring your webhook endpoint in the Stripe Dashboard (Developers → Webhooks), you can select specific event types to receive. Stripe offers "Listen to all events" or "Select events" options. Choosing specific events reduces processing overhead and simplifies your webhook handler logic. You can modify event subscriptions at any time. Only subscribe to events your application actively handles—for example, if you only process payments, subscribe to payment_intent.succeeded and payment_intent.payment_failed rather than all hundreds of event types.


Q: How do I handle webhooks for multiple Stripe accounts?

A: Create separate webhook endpoints (or use query parameters to differentiate) for each Stripe account. Each endpoint uses a different signing secret corresponding to that account. In your webhook handler, use the signing secret that matches the endpoint to verify the signature, then process the event with the appropriate API key for that account. Store account-specific credentials securely in your environment variables or secrets management system.


Q: What's the difference between test and live mode webhooks?

A: Test mode webhooks use test API keys and signing secrets, processing events from test payments, subscriptions, and customers created with test data (credit card numbers like 4242 4242 4242 4242). Live mode webhooks use live API keys and secrets, processing real payment events from actual customers with real credit cards. Events include a livemode boolean field (true for live, false for test). Always verify the livemode flag matches your environment to prevent test events from affecting production data.


Q: How do I debug webhooks that arrive but aren't processed?

A: Start by checking your application logs for errors during event processing. Verify signature verification succeeds (logs should show successful verification). Ensure your webhook handler returns a 200 status code—check Stripe Dashboard webhook logs for response codes. Common issues: exceptions thrown during processing (catch and log them), database connection failures, external API timeouts. Use detailed logging with event IDs to trace processing. Test with the Webhook Payload Generator to reproduce issues locally with the exact payload that failed.


Q: Should I call Stripe's API from webhook handlers?

A: Generally, minimize API calls from webhook handlers to avoid timeouts. The webhook payload usually contains all necessary data in event.data.object. However, if you need the absolute latest data (webhooks contain snapshots from when the event occurred), you can call Stripe's API in your asynchronous processing (after returning 200), not in the webhook endpoint itself. For example: retrieve full customer details if the webhook only includes a customer ID. Always handle API errors gracefully to prevent webhook processing failures.

Next Steps & Resources

You now have the knowledge to implement production-ready Stripe webhook integrations. Here's how to move forward:

Try It Yourself

  1. Set up a Stripe webhook following the setup steps in this guide
  2. Test locally using the Stripe CLI: stripe listen --forward-to localhost:4242/webhooks/stripe
  3. Generate test payloads with our Webhook Payload Generator
  4. Implement signature verification using the code examples for your language
  5. Deploy to production with proper error handling and monitoring

Additional Resources

Official Stripe Documentation:

Testing & Development:

Related Guides on Our Site:

Stripe Support:

Need Testing Help?

Use our Webhook Payload Generator to:

  • Generate realistic Stripe webhook payloads with valid signatures
  • Test signature verification logic without live Stripe events
  • Customize payload values for edge case testing
  • Simulate any Stripe event type instantly
  • Test error handling with malformed payloads

The tool generates properly signed payloads that will pass Stripe's signature verification, making it perfect for unit testing, CI/CD pipelines, and development environments where you need predictable test data.

Conclusion

Stripe webhooks provide a powerful, reliable way to integrate real-time payment and subscription events into your application. By following this guide, you now know how to:

  • ✅ Set up Stripe webhook endpoints in your Dashboard
  • ✅ Verify webhook signatures securely using HMAC-SHA256
  • ✅ Implement production-ready webhook handlers with proper error handling
  • ✅ Handle common payment and subscription events
  • ✅ Test webhooks effectively using the Stripe CLI and our generator tool
  • ✅ Troubleshoot issues and follow best practices for reliability

Remember these key principles for production-grade Stripe webhooks:

  1. Always verify signatures - Never skip this critical security step
  2. Respond quickly - Return 200 within ~5 seconds, process asynchronously
  3. Process asynchronously - Use queues to avoid timeouts
  4. Implement idempotency - Track event IDs to handle duplicate deliveries
  5. Log comprehensively - Include event IDs for debugging and traceability
  6. Monitor actively - Track delivery success rates and processing times

Stripe's webhook system is battle-tested at massive scale, processing billions of events for millions of businesses. With proper implementation following the patterns in this guide, you'll build reliable payment integrations that provide excellent customer experiences.

Start building with Stripe webhooks today, and use our Webhook Payload Generator to test your integration thoroughly before deploying to production.

Have questions or run into issues? Drop a comment below or contact us for assistance with your Stripe webhook implementation.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.