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.

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 will generate a signing secret (starts with whsec_). Store this securely—you'll need it for signature verification.

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 });
});

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 your webhook secret for local testing. Use this temporary secret in your development environment.

Best Practices Checklist

  • Verifies signatures on every request
  • Responds quickly (under 5 seconds, ideally under 1 second)
  • Handles duplicates with idempotency checks
  • Processes asynchronously for long-running tasks
  • Logs everything for debugging and auditing
  • Monitors failures with alerts for repeated errors
  • Handles unknown events gracefully (don't crash on new event types)

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