Operator console (opt-in)
Two embeddable Livewire components for the screens you run — an operator managing every endpoint and browsing the delivery log — as distinct from the customer-facing self-service portal and dashboard. They render inside your layout, on your authorized pages, rather than mounting routes of their own.
These two are unscoped, by design. They list and mutate every tenant's endpoints and deliveries, and the endpoints they register are global (owner-less), so every tenant's events reach them. That is what an operator console is — and it means you must place them behind an operator-only gate. For anything a customer touches, use the tenant-scoped, policy-guarded surfaces instead: the self-service portal for managing endpoints, the dashboard for the delivery log.
composer require livewire/livewire
// bootstrap/providers.php — not auto-registered.
Pushery\Webhooks\WebhooksUiServiceProvider::class,
{{-- your own page, behind your own authorization --}}
<livewire:webhooks.admin.subscriptions />
<livewire:webhooks.admin.deliveries />
Reading the status column
The endpoint list carries one badge per row, and it answers two different questions at once — whether the endpoint is switched on, and whether deliveries to it are working.
| Badge | What it means |
|---|---|
| Active | switched on, and either healthy or not yet scored |
| Degraded | switched on, and its health score has fallen into the degraded band |
| Failing | switched on, and its health score is in the failing band |
| Disabled | switched off — no deliveries are attempted at all |
The bands are the ones the endpoint health score computes, and they
are read straight off health_status. An endpoint that has not been scored yet — a freshly
registered one, or an installation with health switched off — reads Active: it is not a
problem, and coloring it as one is how a reader learns to ignore the color.
Switched-on-and-failing is the state you open this screen for. A badge that answered only "is it switched on" would render that endpoint exactly like a healthy one — deliveries stopped, row still green, and the first person to notice is at the other end of the integration.
Off outranks every band. An endpoint nobody delivers to has no current health, so a switched-off endpoint reads Disabled even if its last known band was failing.
Who switched it off
Disabled has two causes that write the same two columns, and they call for opposite actions:
someone clicked Disable, or the circuit breaker tripped. When the
breaker did it, the row says so beneath the badge, with the failure streak that tripped it.
That distinction is worth reading before you act. Re-enabling clears the streak by design — otherwise the next failed delivery would trip the breaker again immediately — so an endpoint whose destination is broken goes straight back through a fresh failure budget and switches itself off again, with the screen reading the same on every pass.
Available in your own code as well:
$subscription->wasAutoDisabled(); // true only while the breaker is on AND the streak stands
Filtering the delivery log
webhooks.admin.deliveries narrows on five things: status, event type, one endpoint, and the
two ends of a date window. Every one of them is a public property, so an embedding can preset
any of them:
<livewire:webhooks.admin.deliveries :subscription-id="$endpoint->id" from="2026-06-01" />
The two date bounds are Y-m-d and inclusive on both ends — until="2026-06-20" includes
everything that happened on the 20th. A value that is not a date is ignored rather than
raising: the bounds are bound with wire:model.live, so they carry half-typed dates on the
way to whole ones.
webhook_deliveries is range-partitioned by month. A bound on created_at is what lets
PostgreSQL skip the partitions outside it — which is why these filters compare the column
directly instead of going through whereDate(), and why an operator screen that is going to
be left open is better off with a window set.
The endpoint list offered by the filter is capped, because this console is unscoped and that list is every subscription in the installation. When there are more than the cap, the screen says so rather than showing a short list that looks complete.
Checking each action, not only the page
Your page gate is required and nothing here replaces it. What it cannot do is answer at the moment an action runs.
A page gate decides who receives a Livewire snapshot. Every interaction after that — every click on delete, toggle, replay — is a separate request to Livewire's own endpoint, and your gate is not consulted again. Two consequences follow that are easy to miss:
- A capability revoked mid-session keeps working until the reader navigates.
- Embed a component in a second place and it inherits that page's gate, not the one you reasoned about when you first placed it.
Set an ability and the seven mutating actions — create, edit, toggle, rotate,
delete, redeliver, ping — authorize against it on every request:
// config/webhooks.php
'admin' => [
'ability' => 'webhooks.operate',
],
Its delete button calls delete(…), and that method is now destroy(…). The old name still
works, so nothing breaks — except under a strict Content-Security-Policy, where it never
worked: delete is a keyword in Livewire's CSP-safe parser, so the expression read as the
delete operator and the button silently did nothing. Re-publish the view, or change that
one line. The gate ability is still 'delete' and needs no edit.
The action name is passed to the gate, so one ability can answer differently per action instead of forcing you to define seven:
Gate::define('webhooks.operate', fn ($user, string $action) => match ($action) {
'delete' => $user->isAdmin(),
default => $user->isOperator(),
});
The default is null, which means exactly today's behavior: no per-action check at all. For
a rule no ability can express, subclass either component and override
authorizeAction(string $action): void — the same seam, one level lower.
Naming a permission instead: admin.abilities
admin.ability can deny every operatorA permission package that resolves through its own Gate::before hook conventionally reads the
first positional gate argument as a guard name and shifts it off the argument list. The action name travels in
exactly that position, so 'create' becomes the guard, the permission lookup asks for a
guard nobody defined, the hook declines to decide, and the check falls through to an ability
that does not exist — a deny.
Every action then refuses every operator, including the one the permission was granted to, and it refuses silently: nothing throws, nothing is logged, the form just does nothing when submitted. A surface that denies everything looks exactly like a surface that is well guarded, which is why this survived so long.
admin.abilities names an ability per action, and an ability that comes from it is
authorized alone — no argument is passed, so there is nothing for that hook to mistake
for a guard, and a permission name works as itself:
// config/webhooks.php
'admin' => [
'abilities' => ['*' => 'manage webhooks'], // one permission, every action
],
'*' is the catch-all and an exact action wins over it, so you can hold the console at one
capability and lift only the destructive one without enumerating the rest:
'abilities' => [
'*' => 'manage webhooks',
'delete' => 'delete webhooks',
],
Both keys may be set together. The map answers where it names an action (or has '*'), and
ability answers everywhere else with its argument and its behavior unchanged — so adopting
the map for one action does not quietly drop the check on the others. An entry that is not a
non-empty string is ignored rather than denying: a half-written map must not turn into a
console that refuses everyone, which is the failure this whole seam exists to end.
Note what this does not do: it is not tenant scoping. The console still reads every tenant's rows, and no ability makes it safe to show a customer.
Editing an endpoint, and rotating its secret
The endpoint console does both in place, and neither is a convenience.
Rotate is the emergency action. A leaked signing secret has to be rollable from the surface that manages it — without it what is left is tinker or a database write, at the moment speed matters most. Rotating issues a new secret immediately and shows it once; the previous secret stays valid as the verify-only rotation secret until the window closes, so closing the leak does not knock the receiver offline while it redeploys. Both stubs confirm the action first.
Edit is the everyday one. Without it, correcting a URL or an event selection means delete-and-recreate — and that is not the same operation: the endpoint gets a new identity, and its delivery history, its health state and its active secret go with the old row. Editing keeps all three. The destination is re-vetted through the SSRF guard on the way in, so an edit is not a way around the check that vets a registration.
Two details worth knowing before you turn the event catalog on:
- What a populated catalog constrains is registration, not dispatch — the fan-out never consults it, so an application can still emit a type it does not document.
- Whatever an endpoint already holds stays acceptable and stays offered, even once the catalog stops declaring it. The usual order writes the catalog after the endpoints exist, and without that a rename would be refused over a value nobody touched — with no checkbox to remove it by.
The two view variants
The two components ship in two renderings — neutral Tailwind markup that needs no design
system, and markup built from pushery/wirekit. You choose between them in config:
// config/webhooks.php
'ui' => [
'variant' => env('WEBHOOKS_UI_VARIANT', 'auto'), // 'auto' | 'wirekit' | 'plain'
],
auto takes the WireKit rendering when WireKit is registered in your application, and the
neutral one otherwise. There is no minimum-version setting to keep in step: the package's
composer.json refuses a WireKit below the tested floor outright, so an install that
resolves is one that works.
Use plain when your application uses WireKit for its own screens but you would rather
these did not follow it, and wirekit to state the choice explicitly rather than let it
depend on what is registered.
Publishing, and when you actually want it
php artisan vendor:publish --tag=webhooks-ui # neutral Tailwind stubs
php artisan vendor:publish --tag=webhooks-ui-wirekit # WireKit-styled stubs
Both land at resources/views/vendor/webhooks/livewire, and a view you published there
wins over ui.variant — including one published from the WireKit tag before this setting
existed. Publishing is not the only way that counts: any view of yours that resolves for
these components wins, so an override registered from your own service provider is respected
the same way.
A published view is out of the update path. It becomes a copy you own and have to keep
in step with the package by hand, or a fix ships into one and not the other. When a copy
falls out of step nothing goes red — a view renders, and the screen is simply the old one
or the unstyled one. That is what ui.variant is for; publish when you genuinely intend to
own the markup.
Every publish tag the package registers is listed in the publishable tags reference.