Introduction
Stripe webhooks power the async side of most billing systems — subscription renewals, payment failures, refunds. But they come with edge cases that aren't obvious until something breaks in production.
This article covers 7 edge cases I've encountered building BillingWatch, an open-source Stripe anomaly detector for FastAPI apps. Each one silently corrupts billing if you're not handling it.
Edge Case 1: Duplicate Events
Stripe guarantees at-least-once delivery, not exactly-once. The same event can arrive multiple times — especially during retries.
Fix: Idempotency check using event ID before processing:
def handle_webhook(event: dict, db: Session):
event_id = event["id"]
if db.query(ProcessedEvent).filter_by(stripe_event_id=event_id).first():
return {"status": "duplicate_skipped"}
process_event(event, db)
db.add(ProcessedEvent(stripe_event_id=event_id, processed_at=datetime.utcnow()))
db.commit()
Edge Case 2: Out-of-Order Delivery
Network conditions mean customer.subscription.updated can arrive before customer.subscription.created. If you depend on sequential state, you'll get integrity errors.
Fix: Always fetch the latest state from the Stripe API rather than inferring state from event order:
def handle_subscription_updated(event: dict):
subscription_id = event["data"]["object"]["id"]
current_sub = stripe.Subscription.retrieve(subscription_id)
update_local_subscription(current_sub)
Edge Case 3: Missing or Null Metadata
Stripe's metadata field is optional. If your billing logic depends on metadata["tenant_id"] and it's missing, you get a silent KeyError.
Fix: Always use .get() with a fallback:
def get_tenant_from_event(event: dict) -> str | None:
metadata = event["data"]["object"].get("metadata", {})
tenant_id = metadata.get("tenant_id")
if not tenant_id:
logger.warning(f"Missing tenant_id in event {event['id']}")
return None
return tenant_id
Edge Case 4: Signature Verification Failures
Middleware that re-encodes the request body before your handler sees it will break signature verification. Common in FastAPI + proxy setups.
Fix: Verify using the raw request body, not a parsed version:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body() # Raw bytes — do NOT parse first
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
Edge Case 5: Webhook Secret Not Set in Production
If STRIPE_WEBHOOK_SECRET is missing, some implementations skip signature verification entirely — accepting any payload from anyone.
Fix: Fail hard at startup:
if settings.ENVIRONMENT == "production" and not settings.STRIPE_WEBHOOK_SECRET:
raise RuntimeError("STRIPE_WEBHOOK_SECRET must be set in production")
BillingWatch enforces this at startup rather than silently accepting unsigned events.
Edge Case 6: Payment Method Detachment Race Condition
When a customer removes a payment method mid-charge, you can receive both payment_method.detached and invoice.payment_failed for the same action. Processing both can send duplicate dunning emails.
Fix: Track the underlying invoice ID, not just the event type:
def handle_payment_failure(event: dict, db: Session):
invoice_id = event["data"]["object"].get("invoice") or event["data"]["object"].get("id")
if db.query(FailureLog).filter_by(invoice_id=invoice_id).first():
return
record_failure(invoice_id, event["type"], db)
Edge Case 7: Webhook Timestamp Expiry
Stripe rejects webhooks with timestamps more than 5 minutes old. This breaks dashboard replays during debugging.
Fix: Temporarily disable tolerance during replay (never in production):
event = stripe.Webhook.construct_event(
payload,
sig_header,
settings.STRIPE_WEBHOOK_SECRET,
tolerance=None # Disable for replay debugging only
)
Conclusion
Stripe's webhook system is robust, but billing bugs are expensive — and most of these fail silently.
BillingWatch handles several of these out of the box: idempotency keys, startup secret validation, and anomaly detection for payment event patterns. Worth a look if you're building on FastAPI + Stripe.
Which of these bit you in production? Drop a comment.
Top comments (0)