DEV Community

Ray
Ray

Posted on

7 Stripe Webhook Edge Cases That Break Billing (And How to Handle Them)

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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)