Events
Events are the package's main extension point: this is where you notify an endpoint's owner, page on-call, write an audit trail or broadcast a live dashboard. They come in two families, and which one you want depends on one question: do you run the Platform layer?
The transport family — Pushery\Webhooks\Server\Events\*
Dispatched by the delivery engine itself, so they fire for every delivery, whether it came
from Webhooks::dispatch(), a PendingWebhook you built by hand, or a redelivery. They are
scoped to a single HTTP attempt and carry the transport's value object
(WebhookDeliveryData), not a model.
| Event | Fires | Carries |
|---|---|---|
WebhookDeliveryDispatching | once per delivery, synchronously, before it is queued | data |
WebhookAttemptStarting | before each HTTP request | data, attempt |
WebhookAttemptSucceeded | an attempt returned 2xx (the delivery is done) | data, attempt, response |
WebhookAttemptFailed | each failed attempt — fires again on every retry | data, attempt, response?, exception? |
WebhookAttemptRetrying | a retry has been scheduled | data, attempt, delaySeconds |
WebhookAttemptDeferred | a Retry-After beyond the cap was waited out, off the retry budget | data, attempt, delaySeconds, requestedSeconds |
WebhookAttemptsExhausted | once, when the delivery gives up for good; an ordered delivery then holds its stream until it is resent or skipped | data, attempt, response?, exception? |
WebhookOutboxEntryRefused | once, when the receiver refused an outbox entry for good or the delivery gate refused to send it; the entry then holds its stream | entry, response?, exception? |
WebhookOutboxEntryGivenUp | once, when an outbox entry used up server.outbox.max_attempts; it holds its stream like a refused one | entry, response?, exception? |
WebhookOutboxEntryOverdue | once, at the first failed attempt past server.outbox.alarm_after_hours; the outbox keeps trying | entry |
An attempt of an entry of the transactional outbox
raises the attempt events above like any other delivery. The three outbox events carry the
WebhookOutboxEntry instead of the value object, because they speak about the entry, which
outlives each of its attempts.
The domain family — Pushery\Webhooks\Events\*
Dispatched by the Platform layer as it writes the delivery log, so they fire only while
platform.enabled is true. They are scoped to the delivery, not the attempt, and carry
the Eloquent models.
| Event | Fires | Carries |
|---|---|---|
WebhookDeliverySucceeded | the delivery was accepted (2xx) | delivery |
WebhookDeliveryFailed | the delivery ended without succeeding, exhausted or refused unsent — once | delivery, reason |
WebhookDeliveryRateLimited | an over-limit delivery was deferred rather than dropped | delivery, delaySeconds |
WebhookEndpointAutoDisabled | the circuit breaker disabled an endpoint | subscription |
The actor family — who did it, not what happened
Four events answer a different question from every other one on this page. The rest tell you what became of a delivery; these tell you which person acted on an endpoint, which nothing else here can. A delivery log can show an endpoint receiving events for a month and still not say who pointed it at that URL.
| Event | Fires | Carries |
|---|---|---|
WebhookEndpointRegistered | an endpoint was registered | subscription, actor |
WebhookSecretRotated | an endpoint's signing secret was rotated | subscription, actor |
WebhookEndpointDisabled | disable() switched an endpoint off that was on | subscription, actor |
WebhookEndpointEnabled | enable() switched an endpoint on that was off | subscription, actor |
All four fire from WebhookManager, not from the portal's screens, so an endpoint registered
or switched through your own admin screen or service layer is recorded exactly like one in
the shipped portal. The two switches fire only when the call changed the endpoint's state: on
an endpoint that was off already, disable() fires nothing. The circuit breaker does not go
through disable() and fires WebhookEndpointAutoDisabled instead, and an endpoint it switched
off comes back through enable(), so WebhookEndpointEnabled also tells you it receives
deliveries again.
actor is the authenticated user, or null when there is none — a console command, a
seeder, a queued job. Null is information rather than a gap: those are precisely the
registrations an audit must not attribute to whoever was last signed in.
The package writes no audit trail of its own and takes no position on where yours belongs. Hang a listener on these and write wherever it lives:
use Illuminate\Support\Facades\Event;
use Pushery\Webhooks\Events\WebhookSecretRotated;
Event::listen(function (WebhookSecretRotated $event): void {
SecurityLog::record('webhook.secret_rotated', $event->actor, [
'endpoint' => $event->subscription->id,
'url' => $event->subscription->url,
]);
});
Neither secret travels on the rotation event, deliberately: an event is broadcast to every listener, serialized into queue payloads and frequently logged wholesale, so signing material on one is signing material in all of those places. A listener that genuinely needs the value reads it from the subscription it is handed.
The receiving end, and the dashboard
Pushery\Webhooks\Client\Events\InboundWebhookVerified — an inbound delivery was authenticated;
carries the source config name and matchedKeyId, the secret that verified it. It exists for
one question the package could not otherwise answer about itself: during a rotation, is anything
still arriving on the old secret? previous_secret keeps a producer's retired key working while
it migrates, and the window has to be closed by hand — too early and genuine deliveries bounce,
too late and a retired secret keeps working. Listen for matchedKeyId === SecretSet::PREVIOUS and
close the window when it stops appearing.
It fires on every verified delivery, not only on the rare one, and that is deliberate: firing
only on previous would make silence ambiguous — a finished migration and no traffic at all look
identical, and they call for opposite actions. Filter in your listener if you only want the rare
case.
matchedKeyId is current or previous when a scheme verified the delivery, and whatever a
custom verifier reported. For a static secret the two name secret and previous_secret. For
keys from a JWKS document they name the first and the second Ed25519 key the document publishes,
by position, not by age: a JWK carries no reliable age, and its kid is not reported. With a
pinned kid the one key is current. It is null only when a verifier authenticated the request
without naming a key.
confirmingKeyIds lists every key that confirms the delivery on its own, current first. A scheme
stops at the first key that matches, so matchedKeyId cannot tell a producer that signs with both
secrets from one that signs with the new one alone. The list can: ['current', 'previous'] while
the producer still signs with both, ['current'] from the first delivery it signs with the new one
only, which is when the previous secret can go. With one key, a custom verifier, or a scheme of
your own, the list holds matchedKeyId, or nothing when that is null: the package asks only its own
schemes a second time, because yours may watch what it verifies.
tenant is the key of the tenant a config's tenant_resolver named, and null on a config without
one. Its secrets verified the delivery, so the tenant is proven.
Pushery\Webhooks\Client\Events\InvalidWebhookSignature — an inbound request failed verification;
carries the source config name, a coarse reason, and the ip, path and userAgent of the
caller. On a config with a tenant_resolver it also carries tenant, the key of the tenant the
resolver found for the request, or null when it found none. The signature failed, so that is the
tenant the request names, not one it proved to come from: anyone can name one. A listener that
tells the tenant about a refused delivery limits how often it does so per tenant.
Pushery\Webhooks\Client\Events\UnreadableWebhookPayload — an inbound request verified, and then
nothing could read its body; carries the stored call, its source config name and the declared
contentType. $event->call->body() returns the exact unread bytes. This is
the one event on the page that reports something the rest of the pipeline treats as a success:
the call is stored and answered normally, because the delivery is authentic. Without a listener
it is entirely silent, which is exactly the failure it exists to end — a handler handed an empty
payload finds nothing to do, marks the call processed, and the producer never repeats it. See
Receiving.
It carries the stored row rather than the request for a reason worth knowing before you queue a listener on it: a request holds closures and cannot be serialized onto a queue at all, while the row can — but a queued listener then ships the row, body included, inside its job payload. On a queue driver with a message-size limit, keep that listener synchronous, or read what you need from the event and dispatch your own job with just an id.
Both receiving events name their config instead of carrying it
$event->source is the client config's name. WebhookConfig::forName($event->source) gives you
the whole config wherever you need it, including inside a queued listener.
That indirection is the point. A WebhookConfig holds the signing secret and the rotation secret
in cleartext, so an event carrying one puts them wherever the event goes — a log line, a queue
payload, an error reporter's copy of a failed job. None of those is covered by a retention policy
on the delivery tables, and it took no queue to happen: recording the event was enough. Reading
the config back from configuration keeps the secret in exactly one place.
The same applies to the request. InvalidWebhookSignature gives you the caller's ip, the path
they hit and their userAgent — what an abuse listener acts on — rather than the request object.
A listener that needs more of the request runs during the request and can call request()
directly; such a listener must not be queued, because a request cannot be serialized.
Pushery\Webhooks\Dashboard\Events\WebhookRedeliveryRequested — an operator asked the dashboard to
replay a delivery.
Two rules worth pinning up
- "Attempt" is not "delivery".
WebhookAttemptFailedfires on every failed try, so a notification wired to it goes out once per retry. The delivery gives up exactly once, and says so asWebhookAttemptsExhausted(transport) /WebhookDeliveryFailed(domain). Notify from those. - Send-only apps get the transport family only. With
platform.enabled=falsethere is no delivery log and noPushery\Webhooks\Events\*— a listener on them would never fire and nothing would tell you. Listen toPushery\Webhooks\Server\Events\*instead; they are always dispatched.
// Platform app: notify the endpoint's owner once, when the delivery is dead.
Event::listen(function (Pushery\Webhooks\Events\WebhookDeliveryFailed $event): void {
// A refused delivery was never sent: its endpoint was deleted, disabled or moved meanwhile.
if ($event->delivery->status === Pushery\Webhooks\Enums\DeliveryStatus::Refused) {
return;
}
$event->delivery->subscription->owner?->notify(new App\Notifications\EndpointFailing($event->reason));
});
// Send-only app: the same moment, from the engine itself.
Event::listen(function (Pushery\Webhooks\Server\Events\WebhookAttemptsExhausted $event): void {
Log::error('Webhook gave up', ['url' => $event->data->url, 'attempts' => $event->attempt]);
});