Skip to main content

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_id is denormalized across the subscriptions table, the delivery log and the dashboard rollup, and all three share one storage type. It is bigint by default; if your owner models key by UUID or ULID, set platform.owner_key_type (WEBHOOKS_OWNER_KEY_TYPE) to uuid or ulid before 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 of Schema::defaultMorphKeyType(). A global (null) owner works under every setting.

If you fork the create-table migrations, the setting stops following the schema

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. id is the event id the delivery carried, and data is reshaped by the endpoint's payload transform as it stands now, the way a redelivery reshapes it. created_at is 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's meta.next_cursor. meta.has_more says 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 throws FeedCursorExpired: 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 throws InvalidFeedCursor.

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.

It refuses an endpoint the fan-out would have skipped

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.

Top-level field names only

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

Don't call the command from a request

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.