Skip to main content

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.

EventFiresCarries
WebhookDeliveryDispatchingonce per delivery, synchronously, before it is queueddata
WebhookAttemptStartingbefore each HTTP requestdata, attempt
WebhookAttemptSucceededan attempt returned 2xx (the delivery is done)data, attempt, response
WebhookAttemptFailedeach failed attempt — fires again on every retrydata, attempt, response?, exception?
WebhookAttemptRetryinga retry has been scheduleddata, attempt, delaySeconds
WebhookAttemptDeferreda Retry-After beyond the cap was waited out, off the retry budgetdata, attempt, delaySeconds, requestedSeconds
WebhookAttemptsExhaustedonce, when the delivery gives up for gooddata, 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.

EventFiresCarries
WebhookDeliverySucceededthe delivery was accepted (2xx)delivery
WebhookDeliveryFailedthe delivery exhausted its retries — oncedelivery, reason
WebhookDeliveryRateLimitedan over-limit delivery was deferred rather than droppeddelivery, delaySeconds
WebhookEndpointAutoDisabledthe circuit breaker disabled an endpointsubscription

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.

EventFiresCarries
WebhookEndpointRegisteredan endpoint was registeredsubscription, actor
WebhookSecretRotatedan endpoint's signing secret was rotatedsubscription, 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". WebhookAttemptFailed fires on every failed try, so a notification wired to it goes out once per retry. The delivery gives up exactly once, and says so as WebhookAttemptsExhausted (transport) / WebhookDeliveryFailed (domain). Notify from those.
  • Send-only apps get the transport family only. With platform.enabled=false there is no delivery log and no Pushery\Webhooks\Events\* — a listener on them would never fire and nothing would tell you. Listen to Pushery\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]);
});