How to Handle Stripe Webhook Retries Reliably
Stripe retries failed webhooks up to 72 hours. Here's what actually goes wrong, why naive retry handling breaks idempotency, and how to build something that won't blow up in production.
Jason Warner
February 10, 2026
How to Handle Stripe Webhook Retries Reliably
I've debugged enough failed payment flows to know that most teams don't realize they have a problem until a customer emails saying their subscription didn't activate. By then you've already dropped three or four Stripe events, your database is inconsistent, and you're doing manual cleanup at 11pm.
Stripe is actually pretty generous. If your endpoint returns anything other than a 2xx, they'll retry the event for up to 72 hours with exponential backoff. The problem isn't Stripe. The problem is what happens on your end when the same event shows up twice.
Why Stripe Retries in the First Place
When Stripe fires a webhook, it waits up to 30 seconds for a response. If your server is slow, down, or throws an error, Stripe marks the delivery as failed and schedules a retry. The retry schedule looks roughly like this: after a few minutes, then 30 minutes, then a couple hours, and so on — up to 72 hours total.
This is good design on Stripe's part. But it creates a contract: your webhook handler has to be idempotent. The same payment_intent.succeeded event might legitimately arrive three times if your server had a rough few hours.
The Classic Mistake
The naive implementation looks like this:
[HttpPost("stripe/webhook")]
public async Task<IActionResult> HandleWebhook()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], _secret);
if (stripeEvent.Type == "payment_intent.succeeded")
{
var intent = stripeEvent.Data.Object as PaymentIntent;
await _orderService.ActivateSubscription(intent.Metadata["userId"]);
}
return Ok();
}
Looks fine. But ActivateSubscription probably does something like INSERT INTO Subscriptions ... — and the second time that event arrives, you either get a duplicate row or a primary key violation that makes your handler throw a 500, which causes Stripe to retry again. Congratulations, you've built a loop.
The Fix: Idempotency Keys
Every Stripe event has a unique id. Store it. Before processing, check whether you've already handled that event ID.
var eventId = stripeEvent.Id;
var alreadyProcessed = await _db.ProcessedEvents.AnyAsync(e => e.EventId == eventId);
if (alreadyProcessed) return Ok();
// ...process...
_db.ProcessedEvents.Add(new ProcessedEvent { EventId = eventId, ProcessedAt = DateTime.UtcNow });
await _db.SaveChangesAsync();
This works, but it puts the burden on every developer who touches your webhook handler to remember to do this correctly. And in practice, people forget. Or they add a new event type six months later and skip it.
The Harder Part: Delivery Failures You Don't Control
Sometimes it's not about duplicate events. Sometimes your webhook endpoint is behind a load balancer that briefly times out. Or your database is getting migrated. Or you deployed a bad build that crashed the process. Stripe will retry, but if your endpoint is down for longer than 72 hours, the events are gone.
The standard advice is "log everything to a queue" — use SQS, RabbitMQ, or similar to buffer incoming events and process them asynchronously. That way your endpoint always returns 200 quickly, and a separate worker handles the business logic. If the worker fails, you can replay from the queue.
That architecture is correct, but it's also a non-trivial amount of infrastructure for a team that just wants to ship features.
Using Bluejay Relay for Stripe Webhook Reliability
This is exactly the problem Bluejay Relay was built to solve. Instead of your application receiving Stripe webhooks directly, Bluejay Relay acts as the intermediary. Every event is captured, logged, and delivered to your endpoint with automatic retry on failure.
If your endpoint is down, Bluejay Relay queues the delivery and retries it using configurable backoff policies — Linear, Exponential, or none at all, depending on what you need. You can configure retry counts per destination (up to 100 on the Skybound tier). Every retry attempt is logged with the HTTP status code and response body, so you can see exactly what happened.
Even better: the Replay feature lets you re-send any historical event to your endpoint. If you had a bug in your handler for two days and dropped 50 events, you don't need to call Stripe Support. Just filter by date range in the Bluejay Relay logs and bulk-replay the affected events.
Setting Up Stripe → Bluejay Relay → Your App
- Create an Integration in Bluejay Relay and copy the capture URL
- In your Stripe Dashboard, set the webhook endpoint to that capture URL
- Add your app's real endpoint as the Destination in Bluejay Relay
- Configure retry policy on the Destination (I'd recommend Exponential with 5 retries for payment events)
From there, every Stripe event hits Bluejay Relay first. You get a permanent log, automatic retry, and replay capability — without touching your application code.
Still Need Idempotency?
Yes. Even with Bluejay Relay handling retries, your application endpoint can still receive the same event twice if Bluejay Relay retries a delivery that actually succeeded but returned a 500 (network hiccup, etc). Idempotency checking in your handler is still good practice. But now it's a belt-and-suspenders safety net rather than your primary reliability mechanism.
The key insight is separating concerns: let your infrastructure handle delivery reliability, and let your application code focus on business logic.
If you're dealing with Stripe webhook failures, try Bluejay Relay free — no credit card required on the free tier.
Stop losing Stripe webhooks.
Bluejay Relay retries failed deliveries automatically with backoff and a dead-letter queue, so a momentary outage never costs you an event. Free to start, no card.