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 good; an ordered delivery then holds its stream until it is resent or skippeddata, attempt, response?, exception?
WebhookOutboxEntryRefusedonce, when the receiver refused an outbox entry for good or the delivery gate refused to send it; the entry then holds its streamentry, response?, exception?
WebhookOutboxEntryGivenUponce, when an outbox entry used up server.outbox.max_attempts; it holds its stream like a refused oneentry, response?, exception?
WebhookOutboxEntryOverdueonce, at the first failed attempt past server.outbox.alarm_after_hours; the outbox keeps tryingentry

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.

EventFiresCarries
WebhookDeliverySucceededthe delivery was accepted (2xx)delivery
WebhookDeliveryFailedthe delivery ended without succeeding, exhausted or refused unsent — 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​

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.

EventFiresCarries
WebhookEndpointRegisteredan endpoint was registeredsubscription, actor
WebhookSecretRotatedan endpoint's signing secret was rotatedsubscription, actor
WebhookEndpointDisableddisable() switched an endpoint off that was onsubscription, actor
WebhookEndpointEnabledenable() switched an endpoint on that was offsubscription, 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". 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 {
// 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]);
});