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

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 endsuntil="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, 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

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