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
| Event | When to handle | Action |
|---|---|---|
| payment_intent.succeeded | One-time payment completes | Fulfill order, send receipt |
| invoice.paid | Subscription renewal succeeds | Extend access until invoice.period_end |
| invoice.payment_failed | Subscription renewal fails | Notify customer, start dunning |
| customer.subscription.updated | Plan change, quantity update, trial ending | Sync entitlements |
| customer.subscription.deleted | Subscription canceled (immediate or end of period) | Revoke access at period_end |
| charge.dispute.created | Customer files chargeback | Suspend account, alert team |
| checkout.session.completed | Checkout session finishes successfully | Provision 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
- Webhook endpoint registered in Stripe dashboard with correct event types selected
- STRIPE_WEBHOOK_SECRET environment variable set in production
- Idempotency table created with unique index on event_id
- Retention job scheduled (DELETE old events after 7 days)
- Async queue for handlers taking >2 seconds
- Sentry or equivalent error tracking on all handler functions
- Dashboard alert: webhook 4xx/5xx rate spike
- Tested with Stripe CLI
triggerfor 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