Skip to main content

Self-service portal (opt-in)

A tenant-scoped surface where a customer manages its own endpoints — a paginated list with a health badge, a create/edit form that SSRF-vets the URL, a secret panel that reveals and rotates the signing secret, an endpoint health matrix, and a payload-transform editor.

These are real, full-page screens that ship with the package, not a headless seam you have to build against.

Sending a test event

Every row in the endpoint list carries a Test action. It sends one webhooks.ping delivery to that endpoint, so a customer can find out whether its receiver answers before a real event depends on it — which is the whole difference between a test and a lost event.

It is bounded by platform.test_ping.max_per_minute (default 5), and that allowance is per endpoint, not per tenant: a customer with twenty endpoints may send twenty times that in a minute, to twenty destinations it chose. Pair the setting with platform.self_service.max_endpoints_per_tenant if that matters for your installation — unset, the endpoint count is unbounded.

A disabled endpoint is refused rather than pinged. It would otherwise accept the request, record a delivery, and have it dropped at send time — the customer would read "sent" over nothing arriving.

Running out of the allowance is reported to the reader with the number of seconds to wait, not raised as an error: it is an ordinary outcome of pressing a button.

Three steps, all required

# 1. The portal's screens are Livewire components built from WireKit.
composer require livewire/livewire pushery/wirekit
// 2. Register the provider — it is NOT auto-registered.
// bootstrap/providers.php
Pushery\Webhooks\Platform\SelfServicePortalServiceProvider::class,
// 3. config/webhooks.php — switch the layer on.
'platform' => ['self_service' => ['enabled' => true, /* … */]],

Every query is scoped so a tenant only ever sees the endpoints it owns, guarded by the manage-webhook-endpoints gate and a row-level WebhookSubscriptionPolicy. It mounts at route_prefix behind middleware, max_endpoints_per_tenant caps registrations, and the views are publishable (--tag=webhooks-self-service-views) if you are on another UI kit and want to restyle them.

Embedding a panel in a screen you already have

Every panel is registered under a stable alias, so you can drop one into a page of your own rather than sending customers to the portal's URLs:

<livewire:webhooks.self-service.endpoint-list />
<livewire:webhooks.self-service.endpoint-form />
<livewire:webhooks.self-service.secret-panel />

If you do that, you usually do not want the portal's own pages as well — the same surface would gain a second URL beside yours, carrying the portal's middleware rather than your screen's guard. Say so:

'self_service' => [
'enabled' => true,
'register_routes' => false, // components only; mount nothing
],

The panels then drop the links they can no longer resolve — the transform editor, the health board, the back links — instead of failing to render. Everything else works unchanged.

Two things to keep in mind. The panels still authorize manage-webhook-endpoints on every request of their own, so your page gate and theirs both apply. And register_routes: false mounts nothing at all, so the transform editor and health board are unreachable unless you give them routes of your own.

Denying, or hiding

A reader without manage-webhook-endpoints is refused with 403 — the surface exists, you may not have it. That is right for most applications and it is the default.

If your application's convention is to hide rather than to deny, say so:

'self_service' => [
'refuse_with' => 404,
],

"Real, but not yours" also confirms that this installation runs an endpoint portal at all, which is the question a reader guessing URLs is asking. A host that answers 404 everywhere else usually wants this surface to match rather than to stand out — and before this key, each of them wrote the same exception mapping into its own middleware.

The gate itself does not change either way: the reader is refused before any panel renders and on every later interaction. Only the answer differs, and it applies to the portal's pages and to an embedded panel alike. Left unset, the original authorization exception is rethrown untouched, so nothing about a 403 installation changes.

Endpoint ownership is a separate question and needs no configuration: another tenant's endpoint id fails not-found before its policy, everywhere in this package, whatever this is set to.

The delivery log a customer can read themselves

The portal's fourth panel (webhooks.self-service.endpoint-deliveries) lists what was sent to the customer's endpoints — event, outcome, response code, how long it took, and when — newest first, paginated, bounded to a time window, and with a send again action on each row. It can be narrowed to one endpoint, to one outcome, and to an exact day range; the two date bounds are added to the time window rather than used in its place, so they can only narrow what the window already allows and never reach past it. It exists because the rest of the portal could show a customer THAT they had an endpoint and never whether anything had reached it: a receiver seeing nothing arrive has exactly two hypotheses, you did not send and I did not accept, and without this list they cannot rule out the first. The health badge does not answer it either — a score says an endpoint is broadly fine, not whether the order from 14:03 went out.

Two properties are deliberate rather than incidental:

  • Owner-scoped with no join. Every delivery row carries the denormalized (owner_type, owner_id) of its subscription's owner, and the panel compares the whole pair, so there is no relation through which the scope could be widened later. With no tenant resolved it constrains to nothing rather than to everything. A host that shares endpoints can add to that ground set — see Letting a reader see a shared endpoint.
  • The duration beside the response code. The package measures every attempt, so the number is already on the row. It is shown only where a code came back: a failure that never got an answer also has a duration — the time spent failing to get one — and printing that on its own would read as latency.
  • No payload, ever; the error text only if you ask. The outbound payload has its own ability on the operator dashboard and is never read here. The stored error is an HTTP client's exception message and can quote back whatever the receiver wrote — so it is off by default, and the column is not even selected while it is off. Switch it on with platform.deliveries.show_errors where the reader is the party that runs the receiver; the panel's showErrors property can decline it per embedding but can never grant it.
  • Bounded in time by default. platform.deliveries.window_days (30) is a ceiling, not merely a default: the reader may narrow the window and can never widen past it. It is there because webhook_deliveries is partitioned by month and a read with no lower bound on created_at cannot be pruned — it visits every partition there is, and nothing anywhere goes red about it. Set it to 0 to switch the bound off.

Letting a reader see a shared endpoint

The owner pair answers does this row belong to this owner? If your application shares an endpoint — a destination owned by one user and visible to an organization — the question you actually have is may this person see this row?, and the two part company at exactly that point. A member of that organization is not the owner, so under owner scoping alone they see nothing, and no ability you define changes that: the constraint is in the WHERE clause, not in a policy.

Answer it from a service provider:

use Pushery\Webhooks\Platform\Support\ReadableEndpoints;

ReadableEndpoints::resolveUsing(
fn (): array => auth()->user()?->sharedEndpointIds() ?? [],
);

A set of ids, not a predicate, and that is the design rather than a limitation: a closure handed the query could drop the owner scoping, and then this panel's central promise would depend on your code. The resolver can only add, and what it adds is visible in one place. It is called per resolution and never cached, so access you withdraw is withdrawn now rather than at the reader's next navigation.

It reaches the delivery list, the endpoint filter and the filter's option list — two of the three would have been a feature nobody could use.

It widens reading, and only reading

Declaring an endpoint readable grants no right to replay from it. Seeing what was sent is not permission to send it again, and a host that wants to grant both says so twice — see Letting a reader replay a shared endpoint.

Register nothing and nothing changes: the resolver is absent, the id list is empty, and every scoping is exactly what it was.

Letting a reader replay a shared endpoint

The resolver above answers may this person look? Replaying is a second question, and a real policy answers it more narrowly: membership in an organization is enough to see what its destinations received, while causing a fresh HTTP request to leave your installation under one of those destinations is the kind of act you reserve for an administrator.

Under owner scoping alone that second question had no way to say yes — the administrator does not own the row — so the button rendered and then refused, which teaches a reader that the screen is unreliable.

Answer it beside the first, from the same service provider:

use Pushery\Webhooks\Platform\Support\ReplayableEndpoints;

ReplayableEndpoints::resolveUsing(
fn (): array => auth()->user()?->administeredDestinationIds() ?? [],
);

Same shape as its sibling, and the same guarantees: a set of ids rather than a predicate, read on every resolution so a withdrawn right is withdrawn now, and it can only add — the owner path is untouched, and with nothing registered replay is exactly what it always was.

It does not skip your ability

manage-webhook-endpoints, where you define it, still has to pass. This resolver answers whose endpoint, never may this person manage webhooks at all — if you tightened the second, declaring an endpoint replayable does not reopen it.

One invariant worth knowing rather than discovering: replay implies readable. The action loads the delivery row through the read-scoped query before it looks at the endpoint, so an endpoint you declare replayable but not readable yields no row to replay.

Binding the panel to one endpoint

The resolver above answers for the whole request, which is the right shape when your portal is cut per account. It is the wrong shape when your surface is cut per resource — a page that belongs to one destination, where who may read it hangs off a policy over that destination rather than off the signed-in account alone.

There the only way to satisfy a request-wide resolver is to declare every endpoint the account may read anywhere and then lean on the endpoint filter to narrow it to the one whose page this is. Do not: endpointId is a public property, so it is writable from the browser, and a tampered update walks straight across everything the resolver just declared readable. A filter is not an authorization. Narrowing the resolver just before rendering does not work either, because a Livewire update request never runs the page build that would do it.

Pass the endpoint instead:

<livewire:webhooks.self-service.endpoint-deliveries :subscription="$subscription" />

The pin is #[Locked], so it is yours and not the reader's, and it survives every later interaction. An id is accepted where you hold one without the model. While it is set the endpoint filter is not offered at all — a select whose only usable option is the endpoint already pinned is a control that cannot do anything.

It narrows; it never grants

The pin is applied beside the owner scoping rather than in place of it, so pinning an endpoint the reader may not see yields an empty list rather than access to it. A scoping mistake fails closed, which is the only direction this may fail in.

Sending one again

Each row carries a replay action, authorized by the redeliver ability on WebhookSubscriptionPolicy — its own ability rather than update, because causing an outbound HTTP request to leave the installation is a different act from editing a row.

This button makes your server send a request

The destination is a URL the customer registered, so an unbraked replay is an amplifier one customer can point wherever they like. platform.self_service.replays_per_minute (10) bounds it per tenant, the same way the test ping is bounded. A refused replay tells the reader why rather than doing nothing.

A delivery belonging to another tenant fails not-found rather than forbidden — the owner filter runs before the policy, so a probe cannot tell a foreign row from one that never existed, and cannot spend a real tenant's replay allowance either.

The three knobs that were not written down anywhere

Each of these ships on, so a tenant meets it whether or not the installation chose it.

SettingDefaultWhat a tenant runs into
platform.self_service.recomputes_per_minute2The health board's recompute button. It is the portal's most expensive tenant action by a wide margin — two queries per endpoint, synchronously, in the web request, over an endpoint count max_endpoints_per_tenant leaves unbounded by default — so the brake is deliberately low. The scheduled refresh does the same work in the background, so a third press buys nothing the next tick would not.
platform.self_service.secret_reveal_ttl60 (seconds)How long a revealed signing secret stays on screen. The window is enforced on the server, not only by the countdown the panel shows.
platform.self_service.allow_deletetrueWhether a tenant may delete their own endpoint. Turn it off and the control disappears along with the ability.

A non-positive value on either rate limit switches that brake off rather than refusing every attempt — a limit of zero would disable the feature by typo rather than be a setting anyone wants.

The empty state names the retention window (platform.retention_months) on purpose: after it there provably are no rows by design, and "nothing yet" would mislead about the exact question the panel exists to answer.

What a customer may register for

The form offers the event types platform.catalog declares and refuses a type it does not — see the event catalog for what that means when the catalog is empty (it constrains nothing) and how prefix wildcards fit in. The refusal carries the package's own translated sentence rather than the framework's default line.

An endpoint registered before a type left the catalog keeps it: the form goes on offering that type so its owner can drop it deliberately, and a save is never refused over a value the tenant did not touch. Writing a catalog after endpoints exist is the ordinary way to adopt this, and the alternative would strand those rows — the only way out of a refused save would be deleting and re-registering, which mints a new signing secret and a new endpoint id.

Two brakes on what a customer can repeat

max_endpoints_per_tenant bounds how many endpoints a tenant ends up with. It says nothing about how fast, and nothing at all when you leave it unset, so registrations_per_minute (default 10) bounds that separately, per tenant. Set it to null to remove it. It applies to the portal — the human surface; Webhooks::subscribe() is not braked, so a bulk import belongs there.

The cap itself is enforced under a per-tenant lock covering the count and the insert, so two simultaneous registrations cannot both pass it. If a second registration is in flight the first waits briefly and then reads the cap again; only a wait past a few seconds is reported, and it is reported as contention rather than as the cap.

Authorization is fail-closed — you must grant access

The manage-webhook-endpoints gate denies until your app defines a webhooks.manage ability, so registering the layer never silently opens endpoint management to every authenticated user. Opt a tenant in:

Gate::define('webhooks.manage', fn ($user) => $user->isAdmin());

Tenant resolution — override it for a non-Jetstream tenant model

The portal scopes to the owner returned by Pushery\Webhooks\Platform\Support\SubscriptionScope, which by default reads the authenticated user, preferring a Jetstream-style currentTeam when the user model exposes one.

If your endpoints are owned by a Workspace/Account/Organization model (you called Webhooks::subscribe($workspace, …)) but your auth model is a plain User, the default resolver scopes to the user and the customer sees an empty list — register a resolver from a service provider so the two match:

use Pushery\Webhooks\Platform\Support\SubscriptionScope;

SubscriptionScope::resolveUsing(fn () => auth()->user()?->currentWorkspace);

The closure may return an Eloquent model (its morph type + key are derived), an explicit Pushery\Webhooks\Support\TenantIdentity, a [type, id] pair, or null when no tenant is in scope (every query then resolves to nothing — fail-closed, so a null tenant can neither see nor create endpoints).

The dashboard has its OWN resolver — this one does not reach it

DashboardScope holds a separate closure and delegates to nothing. What the two share is the default rule (the authenticated user, preferring a Jetstream currentTeam), which the dashboard re-implements rather than borrows — so they agree right up to the moment you override one of them.

Register the same closure on both, side by side:

use Pushery\Webhooks\Dashboard\DashboardScope;
use Pushery\Webhooks\Platform\Support\SubscriptionScope;

SubscriptionScope::resolveUsing(fn () => auth()->user()?->currentWorkspace);
DashboardScope::resolveUsing(fn () => auth()->user()?->currentWorkspace);

Overriding only this one is quiet, not loud: the dashboard keeps scoping to the User, whose (owner_type, owner_id) is on none of your delivery rows, so the table and every panel render their empty states. Nothing is red — the page loads, and the only hint is the portal beside it showing the rows the dashboard says are not there.