---
title: "Delivery and retries — Webhooks for Laravel | Roundly"
description: "One attempt step by step, the retry schedule, the per-endpoint throttle, auto-disable, redelivery and resending everything after an outage."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/delivery-and-retries
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# Delivery and retries

Every attempt runs in DeliverWebhook on webhooks.queue.outbound. Attempts are counted in the database ($tries = 0), so throttle releases never exhaust the worker’s --tries; $timeout is the total timeout + 20 s.

## One attempt

- Claim the row with the job’s dispatch token — a duplicate or superseded job sends nothing.
- An endpoint disabled or deleted meanwhile cancels the delivery, with no request.
- Throttle per endpoint (rate\_limit\_per\_minute, or outbound.rate\_limit.per\_minute, 60) before any DNS lookup. A throttled attempt parks the row without consuming an attempt; the job is released and claims again.
- Sign with a fresh webhook-timestamp (both secrets during a rotation grace) and send the User-Agent — outbound.user\_agent, blank = "{app.name} Webhooks", or the endpoint’s own user-agent header.
- Send through the SSRF guard: every hop vetted and pinned, connect timeout 5 s, total 10 s across hops, the answer read only up to outbound.max\_response\_bytes (4 096) — with the guard on or off.
- Record an OutboundAttempt: number, series, manual, duration, status, a UTF-8-safe excerpt, response\_truncated, Retry-After, the connected address, an error code and a message of our own — never a vendor’s.

The throttle is [HTTP Client Rate Limits](https://roundly-consulting.com/open-source/docs/http-client-rate-limits-for-laravel/adaptive-limiting.md) — adaptive, honouring the receiver’s Retry-After, releasing the job instead of blocking a worker:

```php
// in effect, per attempt (built through the injected manager, not the facade):
Http::rateLimit(RateLimits::releasingJob($job)->perMinute($n)->by("webhooks:out:{$endpointId}")->adaptive()->maxWait($maxDelay))
```

## Outcomes

| Outcome | Result |
| --- | --- |
| `2xx` | succeeded; the endpoint’s failure counter resets; DeliverySucceeded. |
| `410 Gone` | failed; the endpoint is disabled (gone) while outbound.disable\_on\_gone is on. |
| A blocked destination, a pin that cannot hold, no way to sign | failed at once (terminal). |
| Anything else — 3xx (not followed), 4xx, 5xx, timeout, connection, TLS, DNS | Retried until max\_attempts (8) with DeliveryFailed(final: false, nextAttemptAt); then failed and DeliveryFailed(final: true). |
| An unexpected exception inside the attempt | Reported, recorded as unexpected and retried like the row above — never left sending, and never counted towards the endpoint’s auto-disable. |

Statuses: pending → sending → succeeded | retrying → … | failed | cancelled.

| Error code | Meaning |
| --- | --- |
| `http_status` | The receiver answered with a non-2xx status. |
| `redirect_not_followed` | A 3xx while redirects are off (retried). |
| `timeout · connection · tls` | The network failed. |
| `unresolved` | The host did not resolve (retried). |
| `blocked` | The SSRF guard refused the destination — terminal, stored as e.g. blocked (private\_address). |
| `cannot_pin` | The connection could not be pinned to the vetted addresses — terminal. |
| `too_many_redirects · redirect_downgrade` | Over max\_redirects, or https → http. |
| `signing` | The scheme cannot sign (no SignsOutboundWebhooks, an RFC 9421 endpoint with no key id) — terminal, no request. |
| `worker_lost` | A sending row whose claim expired — the worker died; the sweep records it and retries. |
| `unexpected` | An unexpected exception inside the attempt. |

## Retries

delay(n) = min(base · multiplier^(n-1), cap) — base 60 s, multiplier 4, cap 9 h — then ± jitter (10 %), never under 1 s. A Retry-After (seconds or an HTTP date, any status) raises it to that, capped at outbound.retry.max\_retry\_after (1 day). outbound.retry.schedule replaces the formula with your own list. The defaults:

| Attempt | Delay before it | Since the first |
| --- | --- | --- |
| 1 | — | 0 |
| 2 | 1 m | 1 m |
| 3 | 4 m | 5 m |
| 4 | 16 m | 21 m |
| 5 | 1 h 4 m | 1 h 25 m |
| 6 | 4 h 16 m | 5 h 41 m |
| 7 | 9 h | 14 h 41 m |
| 8 | 9 h | 23 h 41 m |

A delay up to queue.max\_delay (900 s) is a delayed job; longer ones wait for the webhooks:retry sweep — run the scheduler. One sweep dispatches at most outbound.sweep\_batch (1 000) jobs, so a post-outage backlog drains over several runs. The sweep also re-dispatches a job that is late by more than outbound.requeue\_after (each further copy waits twice as long, up to an hour, so a slow queue does not fill with copies) and records worker\_lost for a sending row whose worker died.

## Auto-disable

consecutive\_failures counts deliveries that ended failed — any success resets it, and a delivery whose last attempt was unexpected (a fault on your side) does not count. At outbound.disable\_after (5) the endpoint is disabled with one conditional update, so racing workers fire EndpointDisabled exactly once, and its pending and retrying deliveries are cancelled. A transient outage never disables; only a sustained one does. A 410 Gone disables at once.

## Redelivery

```php
Webhooks::outbound()->redeliver($delivery);   // or: php artisan webhooks:redeliver msg_…
```

For succeeded, failed or cancelled deliveries of an enabled endpoint: a new series, attempts back to 0, the same webhook-id (receivers dedupe on it — send again for a new message), each attempt flagged manual. In flight → DeliveryInProgressException; pruned body → PayloadPrunedException; disabled endpoint → EndpointNotAcceptingException.

## After an outage — every failed delivery at once

```php
$result = Webhooks::for($team)->outbound()->redeliverFailed($endpoint, since: now()->subDay(), includeCancelled: true);

$result->queued;          // redelivered
$result->skippedPruned;   // their body was already pruned — cannot be sent again
```

Every failed delivery of the endpoint (created since $since; with includeCancelled, also the ones its disable cancelled) is redelivered as above, in chunks. The endpoint must be enabled first — enabling does not resend what its disable cancelled:

```bash
php artisan webhooks:enable outbound 12          # an auto-disabled endpoint first
php artisan webhooks:redeliver --endpoint=12 --since=2026-11-07 --cancelled
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
