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