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 — adaptive, honouring the receiver’s Retry-After, releasing the job instead of blocking a worker:
// 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
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
$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 againEvery 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:
php artisan webhooks:enable outbound 12 # an auto-disabled endpoint first
php artisan webhooks:redeliver --endpoint=12 --since=2026-11-07 --cancelledShow 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 cryptoBy donating, you agree to our donation terms.
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.