NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Webhooks for Laravel

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

OutcomeResult
2xxsucceeded; the endpoint’s failure counter resets; DeliverySucceeded.
410 Gonefailed; the endpoint is disabled (gone) while outbound.disable_on_gone is on.
A blocked destination, a pin that cannot hold, no way to signfailed at once (terminal).
Anything else — 3xx (not followed), 4xx, 5xx, timeout, connection, TLS, DNSRetried until max_attempts (8) with DeliveryFailed(final: false, nextAttemptAt); then failed and DeliveryFailed(final: true).
An unexpected exception inside the attemptReported, 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 codeMeaning
http_statusThe receiver answered with a non-2xx status.
redirect_not_followedA 3xx while redirects are off (retried).
timeout · connection · tlsThe network failed.
unresolvedThe host did not resolve (retried).
blockedThe SSRF guard refused the destination — terminal, stored as e.g. blocked (private_address).
cannot_pinThe connection could not be pinned to the vetted addresses — terminal.
too_many_redirects · redirect_downgradeOver max_redirects, or https → http.
signingThe scheme cannot sign (no SignsOutboundWebhooks, an RFC 9421 endpoint with no key id) — terminal, no request.
worker_lostA sending row whose claim expired — the worker died; the sweep records it and retries.
unexpectedAn 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:

AttemptDelay before itSince the first
1—0
21 m1 m
34 m5 m
416 m21 m
51 h 4 m1 h 25 m
64 h 16 m5 h 41 m
79 h14 h 41 m
89 h23 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 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:

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

By 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.