Skip to main content

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 />

The two lists page independently. The endpoint list keeps its page in ?subscriptions= and the delivery log in ?page=, so a link to page 3 of the log opens the endpoint list on its first page.

The page gate reaches the first render​

Your gate decides who receives the console. Every filter, page turn and action after that is a request to Livewire's own endpoint, and Livewire re-applies only persistent middleware there. Its default list holds auth and can: beside a few authentication middlewares of other packages, and no gate middleware you write. A page guarded by a middleware of your own is therefore guarded at its first render and no longer after it, and an operator whose role you revoke keeps reading every tenant's deliveries in the open tab.

Gate the page with can:, which Livewire re-applies on every request, or register your own middleware with Livewire::addPersistentMiddleware(YourGate::class). The console can also ask on every request itself: name a view ability in webhooks.admin.abilities, and both components check it at the first render and on every request after it, reading included.

// config/webhooks.php
'admin' => [
'abilities' => ['view' => 'view webhooks', '*' => 'manage webhooks'],
],

view is asked only by name. Neither * nor webhooks.admin.ability covers reading, so an operator your gate lets read but not act keeps the console after an upgrade.

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.

BadgeWhat it means
Activeswitched on, and either healthy or not yet scored
Degradedswitched on, and its health score has fallen into the degraded band
Failingswitched on, and its health score is in the failing band
Disabledswitched 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.

Why the band and the switch share one badge

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.

The window is what keeps the query cheap

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',
],
Published the console view before 2.0.0?

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 on Livewire 4.4.4 or older. Livewire's CSP-safe build rewrites the call to $wire.delete(…), and the parser those releases bundle refuses delete after the dot, so the button silently does nothing. Livewire 4.4.5 accepts it. Re-publish the view, or change that one line, and the button works on every release. 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. That method is what a subclass may rely on; the rest of either component is not a contract and may change in a minor release.

Naming a permission instead: admin.abilities​

danger
A permission name in admin.ability can deny every operator

A 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, as if it were not there. An unusable exact entry leaves its action under '*', so 'delete' => env('WEBHOOKS_DELETE_PERMISSION', '') with the variable missing keeps delete at the catch-all's capability. An unusable '*' falls through to ability 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.

Publish to change the markup, not to pick a style

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.