Subscriptions and fan-out (Platform layer)
The Platform layer turns the delivery engine into a product. It is on by default; each capability below is individually gated.
Subscriptions and fan-out
Register endpoints per event type and fan an event out to every matching, active subscription:
use Pushery\Webhooks\Facades\Webhooks;
use Pushery\Webhooks\WebhookEvent;
$subscription = Webhooks::subscribe(
owner: $team, // any Eloquent model, or null for a global endpoint
url: 'https://example.com/webhooks',
eventTypes: ['invoice.paid'],
);
$subscription->secret; // reveal once — it signs their deliveries
WebhookEvent::dispatch('invoice.paid', ['invoice_id' => 'in_123'], tenant: $team);
Owner key type — bigint (default), UUID or ULID.
owner_idis denormalized across the subscriptions table, the delivery log and the dashboard rollup, and all three share one storage type. It isbigintby default; if your owner models key by UUID or ULID, setplatform.owner_key_type(WEBHOOKS_OWNER_KEY_TYPE) touuidorulidbefore migrating so the columns are rendered to match.subscribe()rejects an owner whose key does not fit the configured type up front, with a clear error, rather than failing on the first fan-out. Changing the setting on a populated database is a schema migration, not a runtime toggle; it is independent ofSchema::defaultMorphKeyType(). A global (null) owner works under every setting.
Forking them — to partition differently, or to add your own indexes — is an expected thing to
do. From that moment the column comes from your fork and the setting comes from your config,
and nothing holds the two together. The package believes the setting: subscribe()
refuses every owner it declares unfit, the delivery model casts owner_id by it (a UUID
declared bigint reads back as a small integer, and every UUIDv7 owner collapses onto the
same one), and the redelivery policy then compares that value against the tenant and refuses
every tenant its own deliveries — which reads like an authorization decision rather than a
defect.
php artisan webhooks:preflight holds the declaration against the actual column and fails
when they disagree, naming both. It is the onboarding check for exactly this. A migration
run that leaves them contradicting also writes a warning to the log, forked migrations
included — the check hangs off the migrator's event rather than off this package's migration
files, which a forked installation does not have.
Event types match exactly by default. Turn on platform.wildcards to let a subscription list
a prefix wildcard — order.* receives every event under that prefix, so a concrete
order.line.added reaches subscribers of order.line.added, order.line.* and order.*
(one prefix per dot). Each arm stays an indexed JSON-containment lookup, so the fan-out is
still an index scan.
The delivered envelope
Each subscriber receives a signed POST whose JSON body is an envelope:
{
"id": "0192…-uuid",
"type": "invoice.paid",
"created_at": "2026-07-01T12:00:00+00:00",
"data": { "invoice_id": "in_123" }
}
The id is the Standard Webhooks webhook-id and is stable across redeliveries, so consumers
can deduplicate on it.
Keeping an id the event already has
An event that already carries an id in your own records can keep it. Pass it as eventId, and
it becomes the envelope's id, the webhook-id header and the delivery log's event_id, for
every endpoint of the fan-out and for a redelivery:
WebhookEvent::dispatch('invoice.paid', $payload, tenant: $team, eventId: $event->id);
Webhooks::dispatchTo($subscription, 'invoice.paid', $payload, eventId: $event->id);
A receiver that reads both your webhooks and your own records, such as an event feed or an
export, can then match the two. The id has to be a UUID, because the delivery log stores it in a
UUID column, and it is stored in lower case. A UUIDv7 may lie at most a minute ahead of the
server's clock, since forgetEvents() bounds its search by the moment such an id carries. An id
one of the endpoints has already received is refused as well: a second delivery under it is the
duplicate its receiver drops. Each refusal is an InvalidArgumentException that names the id.
Supporting operations: Webhooks::ping($subscription) (a one-off test event),
Webhooks::rotateSecret($subscription), and Webhooks::redeliver($delivery) (a replay that
keeps the original event id).
Catching up: the event feed
A receiver can miss a webhook: it was down for longer than the retries lasted, or it lost what it
received. Webhooks::feed() reads the events one endpoint was sent back out of the delivery log, a
page at a time and with the same ids, so the receiver catches up and drops what it already has.
Serve it from a route of your own, where you decide who may read which endpoint:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Pushery\Webhooks\Exceptions\FeedCursorExpired;
use Pushery\Webhooks\Exceptions\InvalidFeedCursor;
use Pushery\Webhooks\Facades\Webhooks;
use Pushery\Webhooks\Models\WebhookSubscription;
Route::get('api/webhooks/{endpoint}/events', function (Request $request, int $endpoint) {
$subscription = WebhookSubscription::query()->forTenant($request->user()->team)->findOrFail($endpoint);
try {
return response()->json(Webhooks::feed($subscription, $request->string('after')->value() ?: null));
} catch (InvalidFeedCursor $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
} catch (FeedCursorExpired $exception) {
return response()->json(['message' => $exception->getMessage()], 410);
}
})->middleware('auth:sanctum');
A page lists the events under data, each in the envelope the endpoint receives, and says under
meta where to read on:
{
"data": [
{
"id": "0192…-uuid",
"type": "invoice.paid",
"created_at": "2026-07-01T12:00:00.000000Z",
"data": { "invoice_id": "in_123" }
}
],
"meta": { "next_cursor": "MjAyNi0wNy0wMSAxMjowMDowMCswMHwwMTky…", "has_more": false }
}
- The same event, once.
idis the event id the delivery carried, anddatais reshaped by the endpoint's payload transform as it stands now, the way a redelivery reshapes it.created_atis when the delivery was logged. An event appears once however often it was redelivered, and a test ping not at all. - A minute behind. A delivery shows up a minute after it was logged. A dispatch inside a database transaction commits its row later than its creation time says, and the wait keeps such a row from being passed over before it can be read.
- Reading on. Read from the start without
after, then pass each page'smeta.next_cursor.meta.has_moresays whether more is waiting. Past the newest event the page is empty and the cursor stays where it was, so the next request picks up whatever was sent since. The third argument sets the page size, 100 by default and at most 1000. - As far back as the log. The feed reaches back as far as the delivery log keeps its rows,
platform.retention_months. A cursor from before that throwsFeedCursorExpired: the deliveries after it may be gone, so the receiver resynchronizes from your records and reads the feed from the start. A cursor the feed did not hand out throwsInvalidFeedCursor.
Each endpoint has its own feed, because its transform shapes what it receives. A tenant with several endpoints reads each of them.
Delivering to exactly one endpoint
dispatch() fans out. Its third argument narrows to a tenant, and two endpoints of the
same customer share one — so it cannot express "this event, this endpoint". If your
application routes rule → endpoint rather than event → every endpoint of that type, that
is the shape you need:
$delivery = Webhooks::dispatchTo($subscription, 'invoice.paid', ['invoice_id' => 'in_123']);
One subscription in, one WebhookDelivery out — no other endpoint gets a delivery row, not
even one that fails to send. It is the same delivery as a fan-out: the payload goes
through the catalog schema and the offload, and the send re-validates SSRF, signs, honors
the circuit breaker, and is shaped by the rate limit rather than discarded by it.
It is not ping() and not redeliver(). ping() sends a fixed webhooks.ping body to
prove an endpoint answers; redeliver() replays a delivery that already exists. This one
delivers your event, for the first time, to one place.
An inactive or auto-disabled endpoint, or one that never subscribed to that event type,
raises SubscriptionNotListening — it does not receive the event, and it does not silently
get skipped either. Delivering anyway would send an endpoint something it never asked for,
which dispatch() cannot do and this must not become the back door for. Skipping quietly
would hand you a delivery that never happened and no way to notice.
Endpoint lifecycle
Webhooks::disable($subscription) stops delivering while keeping the secret and the history —
it takes effect immediately, at the delivery gate.
Webhooks::enable($subscription) brings it back and clears the consecutive-failure streak:
this is the recovery path for an endpoint the circuit breaker auto-disabled (see
Reliability), and flipping is_active by hand is not enough — the
streak still stands, so the next final failure re-trips the breaker.
Webhooks::unsubscribe($subscription) removes an endpoint permanently, cascading its
delivery-log rows.
The event catalog
An optional event catalog (platform.catalog) documents each type and can carry a JSON
Schema; enable platform.validate_payloads and a non-conforming payload is rejected with
Pushery\Webhooks\Exceptions\InvalidPayloadException before any delivery is created.
A populated catalog is also the allowlist for registrations. The self-service form and the
operator console both refuse an event type the catalog does not declare — because an endpoint
registered for a type nothing publishes looks configured and never fires, and a typo
(user.registred) is indistinguishable from a correct registration until someone notices that
weeks of nothing have arrived.
The catalog ships empty, and an empty catalog constrains nothing: an application that keeps
no catalog goes on registering whatever it likes. Only writing one turns it into a list. With
platform.wildcards on, the prefix wildcards that cover a declared type (invoice.* for
invoice.paid) are accepted alongside it — one that covers nothing is the same typo a level up.
It constrains registration, not dispatch: the fan-out never consults the catalog, so an application can still emit a type it does not document. And an endpoint registered before a type left the catalog keeps it, so writing a catalog after the fact never strands a row.
Endpoint health scoring (opt-in)
Each active endpoint earns a 0–100 score blended from its recent success rate, a p95-latency
penalty and a consecutive-failure penalty, mapped onto a healthy / degraded / failing
band (unknown with no history). The score comes from a single aggregate query:
use Pushery\Webhooks\Platform\Health\EndpointHealth;
$report = app(EndpointHealth::class)->scoreFor($subscription);
$report->score; // 0–100, or null with no history to score
$report->status; // HealthStatus::Healthy / Degraded / Failing / Unknown
$report->successRate; // and the raw signals the score was blended from
$report->p95;
$report->sampleSize;
php artisan webhooks:refresh-endpoint-health caches the score onto the subscription. With
platform.health.enabled, a finished delivery also refreshes its own endpoint's cached score,
and the command is scheduled to sweep every active endpoint.
Payload transforms and versioning (opt-in)
With platform.payload_versioning.enabled, an endpoint may carry a payload_version and/or a
stored declarative transform, and the event data is reshaped for that endpoint before the
body is signed — so the transformed bytes are the signed-and-sent bytes.
The DeclarativePayloadTransformer is safe and data-driven (no callables): include /
exclude field lists, rename, rewrap, and a stamped payload_version. Two endpoints on
the same event with different versions therefore receive different bodies.
Every rule matches the payload's top-level keys. customer.email is not a path into a
nested object — it is a key name with a dot in it, and it matches nothing. The portal's
transform editor refuses one; a rule set written straight into
platform.payload_versioning.versions is not checked, and a dotted name there is a silent
no-op. Reach the nested value by rewrapping or by shaping the event payload itself.
rename resolves every move against the payload it was given, not against its own output,
so a → b, b → c moves two fields rather than one and a swap works. A move onto a name the
payload already carries is refused rather than performed: there is no correct value to pick
when two fields want one name, and dropping one silently would leave the receiver with no way
to know a field was ever there.
Egress allowlist
php artisan webhooks:egress-ips prints the configured core.egress.published_ips
(json/txt/md) for a consumer to allowlist on its firewall. That command reads the list
directly and is unaffected by everything below.
core.egress.enabled is this section's master gate, and it is false. While it is off a
configured core.egress.proxy is ignored — Settings::egressProxy() returns null before
it ever looks at the proxy value, so every delivery goes out direct, bypassing any proxy the
worker's environment names as well. Nothing is logged and no
configuration output changes, so the only symptom is traffic that does not come from where you
expected. Turn the gate on in the same change that sets the proxy:
// config/webhooks.php
'core' => [
'egress' => [
'enabled' => true, // WEBHOOKS_EGRESS_ENABLED
'proxy' => env('WEBHOOKS_EGRESS_PROXY'),
],
],
A proxy that authenticates takes its credentials in the userinfo of its URL, the part before the
host. The engine seals that URL with your application key
wherever a delivery is stored: in the queue, in failed_jobs and in the delivery data every
attempt event carries. Only the transport sees it in the clear, at send time.
The IP pin does not survive a proxy. The SSRF guard vets and pins a destination IP for direct connections. A forward proxy resolves the hostname itself, so the pin is not enforced through it and the anti-rebinding guarantee becomes the proxy's responsibility — your proxy must enforce its own egress control. Leave
core.egress.proxyunset unless the proxy does that.
AsyncAPI export
php artisan webhooks:asyncapi builds an AsyncAPI 3.0 document from the event catalog — one
channel, operation and message per event type, each carrying the type's schema, example and
description.
It prints JSON to stdout by default; pass an optional path to write a file
(php artisan webhooks:asyncapi asyncapi.json), add --format=yaml for YAML (which requires
symfony/yaml — it is not selected automatically), and --title / --doc-version to
override the document metadata.
Publishing it over HTTP
Webhooks::asyncApi() returns the same document as an array, built from the catalog as it is
configured when the request arrives. Serve it from a route of your own, where you decide the
path, the middleware and the caching:
use Illuminate\Support\Facades\Route;
use Pushery\Webhooks\Facades\Webhooks;
Route::get('webhooks/asyncapi.json', fn () => response()->json(Webhooks::asyncApi(version: '2.0.0')))
->middleware('cache.headers:public;max_age=300;etag');
It takes an optional title (your application's name by default) and a document version. The
array encodes to a valid document as it is: an empty catalog still yields {} for the channel,
operation and message maps, which AsyncAPI requires to be objects.
The package registers its console commands only in the console. A route that runs
Artisan::call('webhooks:asyncapi') passes every test, because tests run in the console, and
fails in production with CommandNotFoundException. Use Webhooks::asyncApi() instead.