Engineering

Stripe Webhook Integration: Handling Events Reliably With Idempotency

May 2026 · 10 min read

Stripe webhooks are how your application learns about payments, subscriptions, and refunds. Get the handler wrong and you double-charge customers, miss revenue, or fail compliance audits. Here's the pattern that survives production traffic with a 3-day Stripe retry window.

Events you actually care about

EventWhen to handleAction
payment_intent.succeededOne-time payment completesFulfill order, send receipt
invoice.paidSubscription renewal succeedsExtend access until invoice.period_end
invoice.payment_failedSubscription renewal failsNotify customer, start dunning
customer.subscription.updatedPlan change, quantity update, trial endingSync entitlements
customer.subscription.deletedSubscription canceled (immediate or end of period)Revoke access at period_end
charge.dispute.createdCustomer files chargebackSuspend account, alert team
checkout.session.completedCheckout session finishes successfullyProvision the purchased thing

Handler structure

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

app = Flask(__name__)
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.route("/stripe/webhook", methods=["POST"])
def stripe_webhook():
    payload = request.data  # raw bytes — do NOT use request.json yet
    sig_header = request.headers.get("Stripe-Signature", "")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, endpoint_secret
        )
    except ValueError:
        return jsonify({"error": "invalid payload"}), 400
    except stripe.error.SignatureVerificationError:
        return jsonify({"error": "invalid signature"}), 401

    # Idempotency check
    if already_processed(event.id):
        return jsonify({"received": True}), 200

    handlers = {
        "payment_intent.succeeded": handle_payment_succeeded,
        "invoice.paid": handle_invoice_paid,
        "invoice.payment_failed": handle_invoice_failed,
        "customer.subscription.deleted": handle_subscription_canceled,
    }

    handler = handlers.get(event.type)
    if handler:
        try:
            handler(event.data.object)
        except Exception as e:
            app.logger.exception("webhook handler failed")
            return jsonify({"error": "processing failed"}), 500

    mark_processed(event.id)
    return jsonify({"received": True}), 200

Idempotency

Stripe retries failed deliveries for 3 days. Your handler must produce the same outcome whether called once or twenty times for the same event.

def already_processed(event_id: str) -> bool:
    try:
        db.execute(
            "INSERT INTO stripe_events (event_id, received_at) VALUES (%s, NOW())",
            (event_id,)
        )
        return False
    except UniqueViolation:
        return True

def mark_processed(event_id: str):
    pass  # already inserted above

# Retention job
def cleanup_old_events():
    db.execute("DELETE FROM stripe_events WHERE received_at < NOW() - INTERVAL '7 days'")

Schema:

CREATE TABLE stripe_events (
  event_id TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL
);

Subscription lifecycle

Subscription state changes don't always fire the event you'd expect. The reliable pattern: always re-query Stripe for the current subscription state instead of trusting event payloads alone.

def handle_invoice_paid(invoice):
    if invoice.subscription:
        sub = stripe.Subscription.retrieve(invoice.subscription)
        extend_user_access(
            user_id=invoice.customer_metadata.get("user_id"),
            until=datetime.fromtimestamp(sub.current_period_end)
        )

Local testing with Stripe CLI

brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:5000/stripe/webhook
# Outputs: Your webhook signing secret is whsec_abc123...
# Set STRIPE_WEBHOOK_SECRET=whsec_abc123 in your env

# In another terminal, trigger test events:
stripe trigger payment_intent.succeeded
stripe trigger invoice.paid
stripe trigger customer.subscription.deleted

Async processing

If your handler takes more than 5 seconds, queue the work:

def handle_payment_succeeded(intent):
    # Cheap: dedup + enqueue
    queue.send_message({
        "type": "payment_succeeded",
        "intent_id": intent.id
    })
    # Return immediately, worker processes the actual fulfillment

The Stripe endpoint returns 200 fast. The worker handles slow steps (sending receipts, provisioning, syncing CRM).

Common mistakes

  • Reading request.json before signature check. Stripe signs the raw bytes — JSON re-serialization breaks the signature.
  • Trusting event.data.object without re-querying. Stripe events may arrive out of order. Re-query for current state on important transitions.
  • Returning 200 on processing failures. Stripe thinks success, doesn't retry. You silently lose events. Return 5xx so Stripe retries.
  • Returning 500 on duplicate events. Stripe retries forever; pick 200 once you've confirmed dedup.
  • Hardcoding the webhook secret. Env var or secrets manager only.

Production checklist

  1. Webhook endpoint registered in Stripe dashboard with correct event types selected
  2. STRIPE_WEBHOOK_SECRET environment variable set in production
  3. Idempotency table created with unique index on event_id
  4. Retention job scheduled (DELETE old events after 7 days)
  5. Async queue for handlers taking >2 seconds
  6. Sentry or equivalent error tracking on all handler functions
  7. Dashboard alert: webhook 4xx/5xx rate spike
  8. Tested with Stripe CLI trigger for each event type you handle

Comparison

Concern Solution
Authenticity stripe.Webhook.construct_event on raw bytes
Replay Stripe handles via timestamp tolerance
Idempotency Postgres unique index on event_id
Out-of-order events Re-query Stripe for current state
Slow processing Queue to worker, return 200 fast
Retry behavior Stripe retries 5xx for 3 days exponentially

FAQ

How long does Stripe retry failed webhooks?
3 days with exponential backoff. After that, you can manually re-deliver from the dashboard or via API. Don't rely on manual replay as your primary path.

What if my database is down when the webhook arrives?
Return 5xx. Stripe retries. The webhook will succeed once your DB is back. Don't try to buffer in memory.

Can I have multiple webhook endpoints?
Yes — useful for separating concerns (one endpoint for billing events, another for fraud/disputes). Each has its own signing secret.

How do I migrate webhook endpoints?
Create the new endpoint, configure it in dashboard alongside the old. Let both receive for 1 week. Verify new endpoint logs match old. Disable the old. Stripe doesn't dedup across endpoints — both will process — so your idempotency table protects you.

Need Stripe wired up without billing bugs?

We've integrated Stripe for 15+ European SaaS. Webhooks, subscriptions, dunning, all production-tested.

Book a discovery call

Related Posts

Webhook Security API Rate Limiting
← All blog posts

Stripe webhooks that survive production

Signature, dedup, async, retry — all the parts that matter.

Book a discovery call