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 | data, attempt, response?, exception? |
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 exhausted its retries — 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
Two 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 took a security-relevant action, 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 |
Both fire from WebhookManager, not from the portal's screens, so an endpoint registered
through your own admin screen or service layer is recorded exactly like one registered
through the shipped portal.
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 for a static secret, the JWKS kid when the keys come
from a JWKS document, and whatever a custom verifier reported. It is null only when a verifier
authenticated the request without naming a key.
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.
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 {
$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]);
});