Back to Blog
·Hook Mesh Team

How to Receive Stripe Webhooks Reliably

A comprehensive guide to setting up, verifying, and handling Stripe webhooks in production. Learn best practices for idempotency, event ordering, and building reliable webhook endpoints with Node.js and Python examples.

How to Receive Stripe Webhooks Reliably

How to Receive Stripe Webhooks Reliably

Stripe webhooks notify your application of important events—successful payments, subscription changes, failed invoices. Receiving them reliably in production is harder than it looks.

This guide covers everything from initial setup to production-ready best practices: signature verification, handling duplicates, processing asynchronously, testing, and hardening for failures.

Stripe webhook flow diagram showing the path from Stripe events through signature verification to your application

Why Webhooks Matter for Stripe

Webhooks push critical events to your application in real-time. This matters because:

  • Asynchronous payments: Bank transfers, SEPA don't complete instantly
  • Subscription lifecycle: Know when subscriptions renew, cancel, or fail
  • Compliance: Capture every payment event for accurate records
  • User experience: Real-time UI updates on payment status

Missing a webhook means lost revenue, confused customers, or compliance issues.

Setting Up Your Stripe Webhook Endpoint

Step 1: Create Your Endpoint

Here's a basic HTTP endpoint in Node.js and Python that Stripe can POST events to.

Node.js with Express:

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

const app = express();

// Important: Use raw body for signature verification
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

    let event;

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

    // Handle the event
    await handleStripeEvent(event);

    res.json({ received: true });
  }
);

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Python with Flask:

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

app = Flask(__name__)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
endpoint_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:
        event = stripe.Webhook.construct_event(
            payload, sig_header, endpoint_secret
        )
    except ValueError as e:
        # Invalid payload
        return jsonify({'error': 'Invalid payload'}), 400
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
        return jsonify({'error': 'Invalid signature'}), 400

    # Handle the event
    handle_stripe_event(event)

    return jsonify({'received': True}), 200

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

Step 2: Register Your Endpoint with Stripe

In your Stripe Dashboard, navigate to Developers > Webhooks and click Add endpoint. Enter your endpoint URL and select the events you want to receive.

For most applications, these event types are essential:

Event TypeWhen It Fires
payment_intent.succeededPayment completed successfully
payment_intent.payment_failedPayment attempt failed
customer.subscription.createdNew subscription started
customer.subscription.updatedSubscription plan or status changed
customer.subscription.deletedSubscription cancelled
invoice.paidInvoice payment succeeded
invoice.payment_failedInvoice payment failed
charge.refundedRefund processed
charge.dispute.createdCustomer disputed a charge

After saving, Stripe generates a signing secret (starts with whsec_). Store this securely—you need it for signature verification.

Important: Only subscribe to events your integration actually needs. Listening to all events strains your server and increases attack surface. Start minimal and add events as requirements grow.

Production requirement: Your webhook endpoint must use HTTPS with TLS 1.2 or higher. Stripe rejects HTTP endpoints in production. Verify your SSL certificate is valid and includes intermediate certificates.

Signature Verification: Your First Line of Defense

Never process a webhook without verifying its signature. This ensures the request came from Stripe and wasn't tampered with. See our HMAC-SHA256 webhook signature verification guide for cryptographic details.

Stripe signs every webhook with a timestamp and your endpoint's secret:

  1. Stripe combines timestamp and payload into a signed string
  2. It generates an HMAC-SHA256 signature using your webhook secret
  3. Your server recreates and compares the signature

The code examples above handle this automatically via Stripe's SDK. Never skip this—attackers easily forge payloads without signature verification.

Handling Events: A Production-Ready Approach

Node.js:

async function handleStripeEvent(event) {
  const { type, data } = event;
  const object = data.object;

  switch (type) {
    case 'payment_intent.succeeded':
      await handleSuccessfulPayment(object);
      break;

    case 'payment_intent.payment_failed':
      await handleFailedPayment(object);
      break;

    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await syncSubscription(object);
      break;

    case 'customer.subscription.deleted':
      await handleSubscriptionCancelled(object);
      break;

    case 'invoice.paid':
      await recordInvoicePayment(object);
      break;

    case 'invoice.payment_failed':
      await handleFailedInvoice(object);
      break;

    case 'charge.dispute.created':
      await alertDisputeTeam(object);
      break;

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

async function handleSuccessfulPayment(paymentIntent) {
  const { id, amount, currency, customer, metadata } = paymentIntent;

  // Check for duplicate processing
  const existing = await db.payments.findByStripeId(id);
  if (existing) {
    console.log(`Payment ${id} already processed, skipping`);
    return;
  }

  // Record the payment
  await db.payments.create({
    stripeId: id,
    customerId: customer,
    amount: amount,
    currency: currency,
    metadata: metadata,
    processedAt: new Date()
  });

  // Trigger downstream actions
  await fulfillOrder(metadata.orderId);
  await sendReceiptEmail(customer, paymentIntent);
}

Python:

def handle_stripe_event(event):
    event_type = event['type']
    data_object = event['data']['object']

    handlers = {
        'payment_intent.succeeded': handle_successful_payment,
        'payment_intent.payment_failed': handle_failed_payment,
        'customer.subscription.created': sync_subscription,
        'customer.subscription.updated': sync_subscription,
        'customer.subscription.deleted': handle_subscription_cancelled,
        'invoice.paid': record_invoice_payment,
        'invoice.payment_failed': handle_failed_invoice,
        'charge.dispute.created': alert_dispute_team,
    }

    handler = handlers.get(event_type)
    if handler:
        handler(data_object)
    else:
        print(f'Unhandled event type: {event_type}')

def handle_successful_payment(payment_intent):
    payment_id = payment_intent['id']

    # Idempotency check
    existing = db.payments.find_by_stripe_id(payment_id)
    if existing:
        print(f'Payment {payment_id} already processed, skipping')
        return

    # Record and fulfill
    db.payments.create(
        stripe_id=payment_id,
        customer_id=payment_intent['customer'],
        amount=payment_intent['amount'],
        currency=payment_intent['currency'],
        processed_at=datetime.utcnow()
    )

    fulfill_order(payment_intent['metadata'].get('order_id'))

Common Challenges and Solutions

Challenge 1: Duplicate Events

Stripe may send the same event multiple times. Network issues, retries, and edge cases all contribute to duplicates. Your code must be idempotent—processing the same event twice should produce the same result. Our comprehensive webhook idempotency guide covers patterns for handling this correctly in payment systems.

Solution: Track processed event IDs or use natural idempotency keys:

async function processEventIdempotently(event) {
  const lockKey = `stripe_event:${event.id}`;

  // Try to acquire a lock
  const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 3600);
  if (!acquired) {
    console.log(`Event ${event.id} already being processed`);
    return;
  }

  try {
    await handleStripeEvent(event);
  } finally {
    // Keep the key to prevent reprocessing
  }
}

Challenge 2: Event Ordering

Events can arrive out of order. A subscription.updated event might arrive before subscription.created. Don't assume sequential delivery.

Solution: Use timestamps and fetch current state when needed:

async function syncSubscription(subscription) {
  const existing = await db.subscriptions.findByStripeId(subscription.id);

  // Only update if this event is newer
  if (existing && existing.stripeUpdatedAt >= subscription.created) {
    console.log('Received outdated subscription event, skipping');
    return;
  }

  // For critical operations, fetch the latest state from Stripe
  const current = await stripe.subscriptions.retrieve(subscription.id);
  await db.subscriptions.upsert(current);
}

Challenge 3: Slow Processing

Stripe expects a response within 20 seconds. Long-running tasks will cause timeouts and retries. Understanding webhook retry strategies helps you design systems that handle these retries gracefully.

Solution: Acknowledge immediately, process asynchronously:

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  // Verify signature first
  const event = verifyAndParseEvent(req);

  // Queue for async processing
  eventQueue.add('stripe-webhook', { event });

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

Architecture diagram showing async webhook processing with immediate response and background queue processing

For high-volume applications, consider an architecture where your webhook endpoint only validates signatures and persists events to a queue (Redis, SQS, RabbitMQ). Separate worker processes then handle the business logic. This pattern prevents webhook timeouts and provides automatic retry capabilities through the queue.

Challenge 4: Express Middleware Ordering

A common gotcha in Node.js: if express.json() runs before your webhook route, it parses the body before signature verification, causing verification to fail.

Wrong approach:

// This breaks signature verification
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
  // req.body is already parsed - signature verification fails!
});

Correct approach:

// Skip JSON parsing for webhook route
app.use((req, res, next) => {
  if (req.originalUrl === '/webhooks/stripe') {
    next();
  } else {
    express.json()(req, res, next);
  }
});

// Use raw body for webhook
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handleWebhook
);

Troubleshooting Common Errors

Diagram showing common Stripe webhook errors and their solutions

Signature Verification Failed

Causes:

  • Using parsed JSON body instead of raw body
  • Wrong endpoint secret (test vs production mismatch)
  • Middleware modifying request body before verification

Solutions:

  • Ensure express.raw() or equivalent captures the raw body
  • Dashboard secrets (whsec_...) differ from CLI secrets—use the correct one for your environment
  • Verify middleware ordering as shown above

Timeout Errors (5xx responses)

Causes:

  • Business logic taking longer than Stripe's timeout window
  • Database queries blocking the response
  • External API calls in the webhook handler

Solutions:

  • Return 200 immediately, process asynchronously
  • Move heavy operations to background jobs
  • Set up a message queue for reliable processing

Missing or Duplicate Events

Causes:

  • Server downtime when Stripe attempted delivery
  • Network issues causing retries
  • No idempotency handling

Solutions:

  • Track processed event IDs in your database
  • Use Redis locks for in-progress events
  • Fetch current state from Stripe API when order matters

TLS/SSL Errors

Causes:

  • Invalid or expired SSL certificate
  • Missing intermediate certificates
  • TLS version below 1.2 (Stripe minimum requirement)
  • Cloudflare minimum TLS set to 1.3 (Stripe maxes at 1.2)

Solutions:

  • Test your certificate at SSL Labs
  • Re-install certificate chain with intermediates
  • Configure your server or CDN for TLS 1.2 compatibility

Testing with Stripe CLI

Install Stripe CLI and test locally:

# Login to your Stripe account
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe

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

The CLI displays a temporary webhook secret for local testing. This secret differs from your Dashboard secrets—use the CLI-provided secret when testing locally, and your Dashboard secret in production.

Critical: Never mix test mode and live mode secrets. Stripe signs test and live events with different keys. If your production code uses a test secret (or vice versa), signature verification fails even with correct implementation.

# Verify your environment before deploying
echo "Using webhook secret: ${STRIPE_WEBHOOK_SECRET:0:10}..."
# Should show whsec_... for Dashboard secrets
# or whsec_... (different value) for CLI secrets

Best Practices Checklist

  • Uses HTTPS with TLS 1.2+ and valid certificates
  • Verifies signatures on every request using raw body
  • Responds quickly (under 5 seconds, ideally under 1 second)
  • Handles duplicates with idempotency checks on event IDs
  • Processes asynchronously for long-running tasks
  • Subscribes selectively to only needed event types
  • Logs everything for debugging and auditing
  • Monitors failures with alerts for repeated errors
  • Handles unknown events gracefully (log, don't crash)
  • Uses correct secrets per environment (test vs production)

Adding a Reliability Layer with Hook Mesh

Even with perfect code, production webhook delivery faces challenges: server downtime, deployment windows, traffic spikes, and network issues. A single missed webhook can cascade into bigger problems.

Hook Mesh acts as a reliability layer between Stripe and your application. Instead of pointing Stripe directly at your server, you route webhooks through Hook Mesh, which provides:

  • Automatic retries with exponential backoff when your server is unavailable
  • Event buffering during deployments or outages
  • Delivery guarantees so you never miss critical payment events
  • Real-time monitoring with alerts when deliveries fail
  • Debug tools to inspect and replay any webhook

Setting it up takes minutes: create a Hook Mesh endpoint, update your Stripe webhook URL, and add your destination. Your existing webhook code stays exactly the same—Hook Mesh just ensures every event reaches it.

For startups and SMBs processing real payments, the cost of missed webhooks (failed fulfillment, angry customers, revenue leakage) far outweighs the cost of a reliability layer. Hook Mesh handles the infrastructure complexity so you can focus on your product.

Conclusion

Stripe webhooks require careful implementation: verify signatures, handle duplicates, respond quickly, and build for failure.

Test thoroughly with the Stripe CLI and consider Hook Mesh as your reliability layer for production. For handling multiple payment providers, check out our PayPal webhooks guide. For a complete overview of payment integrations, visit our platform integrations hub.

Related Posts