Sending (Server layer)
Pushery\Webhooks\Server\PendingWebhook is an immutable, fluent builder in the shape of Laravel's own
PendingRequest/PendingMail: every setter returns a clone, so a half-built call is a
reusable template. Pushery\Webhooks\Server\Facades\WebhookSender::to($url) is a thin, discoverable
entry point to the same builder.
use Pushery\Webhooks\Server\PendingWebhook;
PendingWebhook::create()
->url('https://example.com/webhooks')
->payload(['invoice_id' => 'in_123']) // encoded to JSON and signed
->useSecret('whsec_…')
->forEventType('invoice.paid') // recorded and tagged
->dispatch();
Send a raw, pre-serialized body instead of an array with
->sendRawBody($body, 'application/json').
Secret rotation
Sign with the current and previous secret during a rotation window so a consumer that still
holds the old secret keeps verifying while it migrates. For a registered endpoint
(Webhooks::rotateSecret()) the window is bounded by
platform.secret_rotation_window_hours (24 by default) and it closes: once it has, the old
secret is cleared from the row and can no longer sign or verify — which is the whole point of
rotating away from it. php artisan webhooks:revoke-rotated-secrets (scheduled hourly) sweeps
the endpoints that went quiet before their window elapsed.
A delivery carries its secrets sealed in the queued job, so one queued before a rotation signs with the secret it was queued with for as long as it is retried. Keep the window longer than a delivery can stay in the queue, its retries and Retry-After waits included, or such a delivery reaches the consumer signed only with a secret the consumer has already dropped.
PendingWebhook::create()
->url($url)
->payload($payload)
->useSecrets(current: 'whsec_new', previous: 'whsec_old')
->dispatch();
Secrets that are asked for at each attempt
A delivery that has to outlive a rotation it cannot know about, an ordered stream with a long time to keep trying
above all, can ask for its secrets at each attempt instead. resolveSecretsUsing() names a class that implements
Pushery\Webhooks\Server\Signing\ResolvesDeliverySecrets. The delivery then carries no secret in the queue: every
attempt, and every resend through DeliveryStreams, makes the class from the container and signs with the
SecretSet it returns for that delivery at that moment. The class gets the whole WebhookDeliveryData, so it can tell
from the URL or the meta whose secrets it is asked for.
use Pushery\Webhooks\Core\Signing\SecretSet;
use Pushery\Webhooks\Server\Data\WebhookDeliveryData;
use Pushery\Webhooks\Server\Signing\ResolvesDeliverySecrets;
final class ConnectionSecrets implements ResolvesDeliverySecrets
{
public function secretsFor(WebhookDeliveryData $delivery): SecretSet
{
$connection = Connection::query()->findOrFail($delivery->meta['connection_id']);
return $connection->previous_secret === null
? SecretSet::fromCurrent($connection->secret)
: SecretSet::rotating($connection->secret, $connection->previous_secret);
}
}
PendingWebhook::create()
->url($url)
->payload($payload)
->meta(['connection_id' => $connection->id])
->orderedBy("order-{$order->id}")
->retryForSeconds(72 * 3600)
->resolveSecretsUsing(ConnectionSecrets::class)
->dispatch();
It takes the place of useSecret() and useSecrets(), and doNotSign() still sends unsigned. A class that does not
implement the contract is refused with UnknownSecretsResolver when the call is built.
A different signing scheme
The default is the Standard Webhooks HMAC dialect; opt into another (for example asymmetric Ed25519) per call:
use Pushery\Webhooks\Core\Signing\Ed25519Scheme;
PendingWebhook::create()
->url($url)
->payload($payload)
->signUsing(Ed25519Scheme::class)
->useSecret('whsk_…') // base64 Ed25519 secret key
->dispatch();
Every shipped scheme and the exact wire format are on Signatures and interop.
Retries, backoff and Retry-After
The backoff is exponential with full jitter, capped. A retryable 429/503 carrying a
Retry-After header is honored when scheduling the next attempt, clamped to its own ceiling
(server.backoff.retry_after_cap — the longest wait the queue can hold a job for, which is a
different quantity from the jitter cap). When an endpoint asks for longer than that, the
delivery comes back at the cap and the wait is not charged against tries, so a long
rate-limit window cannot exhaust the delivery before the endpoint is ready for it.
On SQS the two caps meet different limits. An ordinary retry is a released job, which SQS holds
back for up to 12 hours, so server.backoff.cap may go above its default there. Coming back at
retry_after_cap dispatches a new job with a delay, which SQS allows for at most 15 minutes, so
on SQS that wait is held to 900 seconds whatever the cap says. An endpoint that still asks for
longer is tried again after those 15 minutes and deferred again, and each wait counts as one of
retry_after_max_deferrals. Redis and the database queue hold either for longer.
A cap of 0 switches the hint off rather than shortening it to nothing: the next attempt is
drawn from the jittered schedule as if no header had arrived, and nothing is deferred. The other
reading would answer Retry-After: 60 with an immediate retry against the endpoint that just
asked for quiet — strictly worse than not honoring the header at all. respect_retry_after => false says the same thing more plainly.
use Pushery\Webhooks\Server\Backoff\ExponentialWithJitter;
PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->maximumTries(5)
->useBackoffStrategy(new ExponentialWithJitter(baseSeconds: 10, capSeconds: 900))
->respectRetryAfter() // on by default
->dispatch();
Retrying a 4xx by its problem type
A 4xx ends a delivery, except the statuses server.retryable_4xx names (408, 425 and 429
by default), because a 400 or a 410 will not pass on a later try. Some endpoints answer "not
now" with a status that otherwise means "never": a paused account behind a 403, for instance,
with an RFC 9457 problem document that says which.
retryableProblemTypes() names those types for one delivery. A 4xx whose body is an
application/problem+json document with one of these types is retried the way a 5xx is:
with the delivery's backoff, within its tries or its retryForSeconds().
PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->retryableProblemTypes([
'https://example.com/problems/account-paused',
'https://example.com/problems/account-frozen',
])
->retryForSeconds(72 * 3600)
->dispatch();
Every other 4xx keeps its classification, and so does a delivery that names no type. A type
is a URI and is compared as written. A body cut at captureResponseBytes() is read as no problem
document, so the capture limit has to hold the endpoint's whole problem document. A status
server.retryable_4xx names is retried whatever its body says.
Retrying for a time instead of a number of tries
retryForSeconds() replaces tries with a time to keep trying. The delivery retries on the same
backoff until the time is up, however many attempts that takes, and the last retry is moved
forward so that it lands on that moment rather than past it. Then the delivery is exhausted like
any other and WebhookAttemptsExhausted fires. The time starts when the delivery is due: after
delayInSeconds(), and for an ordered delivery once its turn has come.
PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->retryForSeconds(72 * 3600) // keep trying for three days
->dispatch();
Such a delivery never defers on a long Retry-After: it has no tries to protect, so its next
attempt waits retry_after_cap like any other retry. A delivery whose time ran out can be sent
again, with the whole time ahead of it once more, through DeliveryStreams::resend() below.
Delivering in order
orderedBy() puts a delivery into a stream with every other delivery to the same URL under the
same key. A stream goes out one delivery at a time, in the order the deliveries were dispatched,
and each one only after the one before it succeeded. A delivery under another key, or to another
URL, is not held up.
PendingWebhook::create()
->url($url)->payload($event)->useSecret($secret)
->orderedBy("order:{$order->id}")
->dispatch();
Turn on server.ordering.enabled first. The streams live in a webhook_delivery_streams table,
which the Server layer migrates while that switch is on; it holds only the deliveries that have
not succeeded yet. A delivery takes its place in the stream when it is queued, after the
surrounding database transaction commits, so a dispatch that is rolled back takes no place, and
the order of a stream is the order in which the dispatches committed.
A delivery whose turn has not come waits in the queue: its job comes back after a second, then
after twice as long each time, a minute at most, and none of that counts against its tries. So an
ordered delivery needs a connection that can hold a job back. It is refused at dispatch with
OrderedDeliveryUnavailable while the switch is off, through dispatchSync(), and on the sync
connection or an SQS FIFO queue.
Anything but a success holds the stream. A delivery the endpoint refused, one that ran out of
tries or time, and one the delivery gate refused keep their place, and the deliveries behind them
wait. WebhookAttemptsExhausted carries the delivery's data, and
Pushery\Webhooks\Server\DeliveryStreams decides what happens next:
use Pushery\Webhooks\Server\DeliveryStreams;
use Pushery\Webhooks\Server\Events\WebhookAttemptsExhausted;
Event::listen(function (WebhookAttemptsExhausted $event): void {
if ($event->data->orderingKey() === null) {
return;
}
$status = $event->response?->status;
if ($status !== null && $status >= 400 && $status < 500) {
app(DeliveryStreams::class)->skip($event->data); // refused for good: the stream moves on
}
// Anything else holds the stream: raise an alert, and resend() once the endpoint is back.
});
resend() queues the delivery again under the same message id, with no attempt counted and its
whole time to keep trying ahead of it, from the place it holds in its stream. Dispatching a
delivery with the same message id into the stream again lifts the hold as well, and the new
dispatch goes out from that place. skip() takes a delivery out of the stream, so the next one
goes out; a delivery that is skipped before its turn comes is not sent at all, and is reported as
refused. Both take the data from the event, so a listener is the usual place to call them.
A transactional outbox
A delivery dispatched inside a database transaction is queued after the commit, so a rollback
sends nothing. What that cannot cover is the moment after the commit: a queue that refuses the
job, or a process that dies before it pushes it, loses the webhook while the change it reports
stays. Pushery\Webhooks\Server\Outbox closes that gap. The delivery becomes a row in the same
transaction as the change, so the two exist together or not at all.
use Illuminate\Support\Facades\DB;
use Pushery\Webhooks\Server\Facades\WebhookSender;
use Pushery\Webhooks\Server\Outbox;
DB::transaction(function () use ($order, $endpoint): void {
$order->markPaid();
app(Outbox::class)->record(
WebhookSender::to($endpoint->url)
->payload(['order' => $order->id, 'status' => 'paid'])
->useSecret($endpoint->secret)
->forEventType('order.paid')
->orderedBy("order:{$order->id}"),
);
});
Turn on server.outbox.enabled first. The entries live in a webhook_outbox table on the
package's connection, which the Server layer migrates while that switch is on, and record()
writes into the transaction open on that connection. It refuses with OutboxUnavailable while the
switch is off and when no transaction is open there. So where webhooks.database.connection
names a connection of its own, the change has to run on that connection too; a change on another
database cannot commit together with the entry.
After the commit, a queued job sends the entry, onto the queue and connection the Server layer is
configured with. The webhooks:outbox sweep runs every minute and sends what such a job missed:
an entry whose job never got onto the queue, a retry whose time has come, and the next entry of a
stream. The outbox decides the retries itself. Each attempt goes through the delivery engine once,
with the same signing, SSRF guard and response classification as a queued delivery, and raises
the same attempt events, so the standalone delivery log
records it too. A failure that can pass is tried again after the delivery's backoff, as often as
it takes, or until server.outbox.max_attempts failed attempts when you set one. Every attempt
carries the same body and the same message id, which a receiver dedupes by.
An ordering key works as it does for ordered delivery: the entries of a stream go out one at a time, in the order their transactions committed, and each one only after the one before it was delivered or skipped. Two transactions recording into the same stream take turns at its counter until the first one ends, which is what keeps that order.
A refused entry holds its stream. One the receiver refused for good, one the delivery gate
refused, and one the outbox gave up on keep their place, and the entries behind them wait. Three
events tell you, each carrying the WebhookOutboxEntry, and the outbox decides what happens next:
use Pushery\Webhooks\Server\Events\WebhookOutboxEntryRefused;
use Pushery\Webhooks\Server\Outbox;
Event::listen(function (WebhookOutboxEntryRefused $event): void {
app(Outbox::class)->skip($event->entry); // the later entries of its stream go out
});
| Event | Fires |
|---|---|
WebhookOutboxEntryRefused | once, when the receiver refused the entry for good or the delivery gate refused to send it |
WebhookOutboxEntryGivenUp | once, when server.outbox.max_attempts failed attempts are used up |
WebhookOutboxEntryOverdue | once, at the first failed attempt past server.outbox.alarm_after_hours after the entry was recorded; the outbox keeps trying |
resend() sends an entry again from the start, with no attempt counted, from the place it holds
in its stream. skip() takes it out of its stream unsent. Delivered and skipped entries are
pruned after server.outbox.prune_after_days; a refused one and one given up stay until you
resend or skip them. The entry stores the delivery sealed under the application key, beside the
columns a query reads: the URL without its credentials, the event type, the meta you passed, where
it stands and how its attempts went. To erase what an entry carries before its time, delete it by
those columns.
With Webhooks::fake() on, record() records the delivery on the fake and writes no entry, the
way dispatch() records one and queues nothing.
Timeouts, SSRF, mutual TLS, proxy
Every outbound URL is vetted by the shared SSRF guard, and a direct connection is pinned to
the validated IP (see Security). Routing through useProxy() hands name
resolution to the proxy, so the pin no longer applies — the proxy must enforce its own egress
control.
PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->connectTimeoutInSeconds(3)
->timeoutInSeconds(5)
->verifySsl(true) // or a CA bundle path
->useMutualTls(cert: '/path/client.pem', key: '/path/client.key')
->useProxy('http://proxy.internal:8080')
->dispatch();
An option of your own on every delivery. A middleware of yours on Laravel's HTTP client can
read its own key off each request, for instance the purpose an egress policy asks every outbound
call for. Name it in server.request_options and every delivery carries it:
'request_options' => ['egress_purpose' => 'webhook-delivery'],
It is read when the delivery is sent. A key Guzzle reads itself is refused with an
InvalidArgumentException: the transport owns redirects, TLS verification, the response sink,
the timeouts, the proxy and the IP pin, and none of them may be loosened from here.
Metadata, tags, queue, connection
Attach arbitrary meta, add Horizon tags, and choose the queue/connection the delivery job
runs on:
PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->meta(['tenant_id' => $team->id])
->withTags(['billing', "team:{$team->id}"])
->onQueue('webhooks')
->onConnection('redis')
->dispatch();
Terminal methods: ->dispatch(), ->dispatchIf($cond), ->dispatchUnless($cond),
->toDeliveryData() (the immutable value object the job carries) — and ->dispatchSync(),
which comes with a caveat worth knowing before you reach for it.
The job holds the body it signs and sends, so its size follows the webhook's, and Laravel's
queue encoding escapes the quotes and slashes in it: measured, the queued job came out at about
the size of the body for plain text and up to one and a half times it for JSON full of quoted
strings. server.large_payload moves the delivery log's copy of a large payload to a disk, not
the queued one.
That matters on SQS, which refuses a message above 1 MiB when the webhook is dispatched. From
Laravel 13.9 the sqs connection can keep such a message in separate storage and queue a
pointer instead (its overflow option in config/queue.php, switched on by
SQS_OVERFLOW_ENABLED). On an earlier version, put webhooks that can approach that size on a
connection without the limit, such as Redis or the database.
dispatchSync() gets no retriesThe sync connection has no worker, so a released job is never picked up again. A delivery
that fails in a retryable way — a 5xx, a timeout, a reset — therefore ends after one
attempt rather than using its configured tries, and the failure is reported as
QueueCannotRetry so the row says why rather than looking like a dead endpoint.
The same applies to any host running QUEUE_CONNECTION=sync, whatever it dispatches with.
webhooks:preflight warns when the server layer resolves to it.
Standalone delivery persistence (opt-in)
When you drive PendingWebhook directly without the Platform layer and still want a
persisted, prunable record of every delivery, enable server.persistence.enabled: a listener
upserts each attempt into a webhook_server_deliveries table keyed by the message id, and
rows older than prune_after_days are removed by the scheduled model:prune. A row keeps the
endpoint URL without its userinfo and its query, the parts a credential travels in, by the same
rule its error text is redacted with.
Off by default — when the Platform layer runs it owns the delivery log instead, so the two never double-log.