Replaying & retries
How XPay automatically retries failed deliveries, and how to manually resend an event from the Workbench.
A webhook delivery can reach your endpoint more than once for two distinct reasons. XPay automatically retries when your handler returns non-2xx or times out. You can manually replay a delivery yourself from the Workbench when you need to fix a handler bug and re-feed the event. Both arrive as ordinary POSTs your verifier already handles; the difference is who initiated them.
Automatic retries
A delivery is "failed" if any of these happen:
- Your handler returns a non-2xx HTTP response.
- Your handler doesn't respond within 30 seconds per attempt.
- The TCP connection fails (DNS, refused, reset, TLS error).
When an attempt fails, XPay schedules the next attempt and continues until it succeeds or hits the max attempt count.
| Attempt | Fires after the previous attempt by |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
Five attempts total. After the fifth fails, XPay marks the delivery failed and stops retrying. The total window from first attempt to give-up is roughly 2 hours 35 minutes.
A delivery succeeds on the first 2xx response. As soon as one attempt returns 2xx, the delivery is succeeded and no further retries happen, even if you later return an error to a duplicate.
When XPay gives up
After the fifth failed attempt, the delivery's status is failed. The endpoint stays enabled and continues to receive new events. XPay does not auto-disable an endpoint based on delivery failures.
If your endpoint has been failing for a while and you want to be notified, subscribe to the webhook_endpoint.delivery_failed event on a separate, healthy endpoint. It fires when a delivery exhausts all retries and carries the failed delivery's URL, the original event type, the attempt count, and the last error returned. Use it to page yourself when production webhooks start failing.
A failed delivery isn't lost. The Workbench's Events tab keeps deliveries for 90 days and you can replay any of them once your handler is fixed.
Manual replay
The Workbench's Events tab shows every event your account has produced in the last 90 days, along with each delivery to each endpoint and every attempt within it. On any delivery row there's a Resend button that POSTs the same JSON payload to the same endpoint a fresh time.
Reach for replay when:
- You shipped a bug, your handler dropped a real event, and you need to re-feed it now that the bug is fixed.
- You're debugging a new handler against historical data.
- You added a new event type to your subscription and want to backfill recent events into the new handler.
Replays are not a substitute for fixing the underlying handler. If your handler still 500s, replay just produces another failed delivery.
What's different from a retry
A manual replay is fire-and-forget: single attempt, no retry schedule. If your endpoint returns non-2xx to a replay, XPay does not retry it. Click Resend again from the Workbench when you're ready for another shot.
Replays also don't update the original delivery's status. They create a new sibling delivery alongside the original, and the Events tab groups them together by URL so you can see the whole timeline against one endpoint at once.
For the click-by-click walkthrough of the Events tab (filtering, drilling into attempts, reading request and response bodies), see Workbench → Events panel.
What replays look like on your end
Your handler can't easily tell a replay from an original or from an automatic retry. They all arrive as POSTs to the same URL with the same JSON body.
What stays the same on every retry or replay of a single event:
event.id, the unique event ID.event.type, the kind of event it is.event.data.object, the resource payload.- The raw bytes of the JSON body.
What changes on every attempt or replay:
- The
tvalue inXPay-Signatureis the timestamp of this delivery attempt, not the original event. - The
v1signature inXPay-Signatureis recomputed against that freshtvalue.
Two consequences for your verifier:
- The replay window is per-attempt. A replay arriving 3 days after the original event still has a
tvalue within seconds of "now," so theabs(now - t) > 300check passes. Don't comparetagainst the resource'screatedtimestamp; they diverge by design. - Signature verification works the same. Recompute against the fresh
tand the raw body using your endpoint's signing secret. See Verifying signatures.
Build idempotency on event.id
Because every retry and every replay carries the same event.id, the right key for "have I already handled this?" is event.id, not the timestamp or any field on the resource.
async function handleEvent(event: { id: string; type: string; data: unknown }) {
// First-write-wins on event.id
const inserted = await db.processedEvents.insertIfNew(event.id);
if (!inserted) return; // already processed; no-op
// Real work happens here
}insertIfNew is one row in a table of processed event IDs (or a Redis SET NX if you prefer). The work below it runs at most once per event, no matter how many times XPay retries or you replay.
For the full pattern (verify, dedup, ack fast, work in a queue), see Verifying signatures → Idempotency.
Where to next
Workbench: Events panel
The full walkthrough of the Events tab. Filter, inspect attempts, click Resend on a delivery.
Verifying signatures
Recompute the signature on every retry and replay. Idempotency on event.id.
Setting up an endpoint
Add an endpoint, pick events, copy the signing secret. Subscribe to webhook_endpoint.delivery_failed on a separate endpoint to be paged on outages.
Local development
Tunnel deliveries to your laptop while you debug.
Event reference
Every event you can subscribe to, when it fires, and the object it carries.