Stripe Webhook Reliability: Never Miss an invoice.paid Again
Stripe webhook failures cause billing bugs, missed fulfilment, and silent data inconsistencies. Here's how to make Stripe webhook delivery bulletproof.
Stripe webhooks carry some of the most business-critical events in a SaaS application. invoice.paid triggers subscription activation. payment_intent.succeeded unlocks feature access. customer.subscription.deleted should trigger offboarding. When these events fail to deliver — and they do fail — the consequences range from annoying to catastrophic.
Why Stripe webhook delivery fails
Stripe retries failed deliveries for up to 72 hours using an exponential backoff schedule. This sounds robust, but the retry window has limits:
Deployment windows: A 10-minute deployment that returns 503s can cause Stripe to skip several retry attempts. If your server is unresponsive during Stripe's early retry window, you may lose the most critical delivery attempts.
Handler timeouts: Stripe expects a response within 30 seconds. If your invoice.paid handler runs database migrations, sends emails, and calls external APIs synchronously, it will occasionally time out — and Stripe sees a failed delivery even if your processing eventually succeeds.
Partial success: Your endpoint returns 200, but an exception is thrown after the response is sent (a background task, an async operation). Stripe considers it delivered. Your database never gets updated.
Secret misconfiguration: A wrong webhook signing secret causes every event to fail signature verification. These failures are logged but often missed for hours.
The idempotency requirement
Stripe's retry model means your handler will receive the same event more than once. This is by design. Your handler must be idempotent — processing the same invoice.paid twice should have the same effect as processing it once.
Common approaches:
- Store the Stripe event ID in your database and skip processing if already seen
- Use Stripe's
Idempotency-Keyheader on any API calls you make in response to the event - Design state transitions as "insert if not exists" rather than "increment"
Without idempotency, retries cause duplicate charges, double credits, or repeated fulfilment emails.
Signature verification
Stripe includes a Stripe-Signature header on every webhook. You must verify this before processing. The verification checks a HMAC-SHA256 signature and a timestamp to prevent replay attacks. If you're using stripe.webhooks.constructEvent(), you're doing this correctly.
Watch out for:
- Body parsing middleware that modifies the raw body before verification (Express's
express.json()must run after signature verification) - Clock skew — Stripe's timestamp tolerance is 5 minutes by default; make sure your server clock is synced
- Multiple endpoints with different secrets — verify each event with the correct secret for that endpoint
Critical Stripe events to protect
Not all Stripe events are equally important. These are the ones where a dropped delivery causes real damage:
| Event | What breaks on missed delivery |
|---|---|
invoice.paid |
Subscription not activated, feature access not granted |
invoice.payment_failed |
User not notified, dunning not triggered |
customer.subscription.deleted |
Subscription not cancelled in your app |
payment_intent.succeeded |
One-time purchase not fulfilled |
checkout.session.completed |
Order not processed |
customer.subscription.updated |
Plan change not reflected |
Building a reliable Stripe webhook pipeline
The most robust pattern separates receipt from processing:
- Your endpoint acknowledges Stripe immediately with
200 OKand enqueues the event - A background worker processes the event asynchronously, with retries and DLQ on failure
- If the worker fails, the event is retried independently of Stripe's delivery schedule
This decouples your processing reliability from Stripe's retry window. Even if processing fails days later, the event is held in your queue until it succeeds.
Charon Gate implements this pattern as a managed service: Stripe sends to your Charon Gate ingest URL, which acknowledges immediately, verifies the Stripe-Signature, and queues the event for forward delivery. If your application server fails, Charon Gate retries with backoff. If all retries fail, the event enters the DLQ — and you get a Slack alert, not a customer complaint.
Your application never receives a Stripe event it wasn't able to process successfully. invoice.paid always fires. Subscriptions always activate.