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

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

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 ignoredSettings::egressProxy() returns null before it ever looks at the proxy value, so every delivery goes out direct. 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'),
],
],

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.