The Webhooks facade
Everything goes through one facade, RoundlyConsulting\Webhooks\Facades\Webhooks (global alias Webhooks), over the WebhooksManager. Flat verbs send; for($owner) scopes everything to one owner; inbound() and outbound() manage each direction:
use RoundlyConsulting\Webhooks\DataTransferObjects\PruneOptions;
use RoundlyConsulting\Webhooks\Facades\Webhooks;
// Sending
Webhooks::send($endpoint, 'invoice.paid', ['invoice' => 42]); // one endpoint
Webhooks::send($endpoint, 'invoice.paid', $payload, id: 'invoice-42-paid'); // idempotent
Webhooks::broadcast('system.maintenance', $payload); // owner-less endpoints only
Webhooks::for($team)->broadcast('invoice.paid', $payload); // every subscribed endpoint of $team
// The two directions — app-wide, or scoped to one owner
$created = Webhooks::inbound()->create(handler: 'billing');
$endpoints = Webhooks::for($team)->outbound()->endpoints();
// Registries, retention and testing
Webhooks::schemes()->extend('github', GitHubScheme::class);
Webhooks::handlers()->register('billing', BillingWebhookHandler::class);
$result = Webhooks::prune(new PruneOptions(dryRun: true));
$fake = Webhooks::fake();Manager methods
| Method | Returns | Does |
|---|---|---|
send(OutboundEndpoint $endpoint, string $eventType, array|Arrayable|JsonSerializable $payload, ?string $id = null) | OutboundDelivery | Queue one signed delivery; with $id a repeat returns the first delivery and queues nothing. |
broadcast(string $eventType, array|Arrayable|JsonSerializable $payload, ?Model $owner = null) | Collection<int, OutboundDelivery> | Every enabled, subscribed endpoint of $owner; null means owner-less endpoints only. |
for(Model $owner) | Accessors\OwnerWebhooks | A handle scoped to one owner: inbound(), outbound(), broadcast(), owner(). |
inbound() | Accessors\InboundWebhooks | Receiving: endpoints, the pipeline, verification, replay, the log. |
outbound() | Accessors\OutboundWebhooks | Sending: endpoints, send, ping, redelivery, the sweep, the log. |
schemes() | Accessors\SchemeRegistry | The signature-scheme registry. |
handlers() | Accessors\HandlerRegistry | The inbound handler registry. |
prune(?PruneOptions $options = null) | PruneResult | Retention — what webhooks:prune runs. |
Webhooks::fake() | Testing\WebhooksFake | Facade only — the recording fake, also bound in the container. |
Once Webhooks::fake() has run, the fake’s sent(), broadcasts(), redelivered(), replayed() and every assert…() are callable on the facade too — see Testing.
inbound() — InboundWebhooks
| Method | Returns | Does |
|---|---|---|
create(?string $handler = null, string $scheme = 'default', ?string $name = null, ?string $secret = null, ?InboundEndpointOptions $options = null, ?Model $owner = null) | CreatedInboundEndpoint | A new endpoint: endpoint, url, secret (shown once). Unknown handler or scheme throws. |
update(InboundEndpoint $endpoint, InboundEndpointChanges $changes) | InboundEndpoint | Name, handler, scheme, options; clear resets nullable fields. |
enable($endpoint) · disable($endpoint) | InboundEndpoint | A disabled endpoint answers 404 like an unknown one. |
delete(InboundEndpoint $endpoint) | void | Soft delete; the URL answers 404 and the secrets are wiped at once. |
rotateSecret(InboundEndpoint $endpoint, ?CarbonInterface $grace = null, ?string $secret = null) | RotatedSecret | A new (or imported) secret; the old one verifies until the grace ends. |
find(int|string $id) · endpoints() | InboundEndpoint · Collection | Scoped handles see only their owner’s endpoints (EndpointNotFoundException). |
receive(string $key, Request $request) | Response | The whole inbound pipeline — what the route runs. |
verify(InboundEndpoint $endpoint, Request $request) | VerifiedWebhook | The scheme only: no size check, no dedupe, nothing stored (an RFC 9421 nonce is recorded at once). |
replay(InboundDelivery $delivery) | InboundDelivery | Run the handler again on the stored payload, flagged as a replay. |
deliveries(?InboundEndpoint $endpoint = null) | Builder<InboundDelivery> | The inbound log, as a query. |
outbound() — OutboundWebhooks
| Method | Returns | Does |
|---|---|---|
create(string $url, array $events = ['*'], ?string $name = null, array $headers = [], string $scheme = 'default', ?OutboundEndpointOptions $options = null, ?Model $owner = null) | CreatedOutboundEndpoint | A new endpoint; the URL is judged by the SSRF guard. endpoint, secret (shown once). |
update(OutboundEndpoint $endpoint, OutboundEndpointChanges $changes) | OutboundEndpoint | A new URL is judged again; new headers replace the old set. |
enable($endpoint) · disable($endpoint) | OutboundEndpoint | Disable fires EndpointDisabled(manual) and cancels pending deliveries; enable resets the failure counter. |
delete(OutboundEndpoint $endpoint) | void | Soft delete; pending deliveries cancelled; secrets, URL and custom headers wiped at once. |
rotateSecret(OutboundEndpoint $endpoint, ?CarbonInterface $grace = null) | RotatedSecret | Both secrets sign during the grace. |
find(int|string $id) · endpoints() | OutboundEndpoint · Collection | The endpoints, owner eager-loaded. |
send($endpoint, string $eventType, $payload, ?string $id = null) | OutboundDelivery | The same as Webhooks::send(). |
ping(OutboundEndpoint $endpoint) | OutboundDelivery | A webhook.ping test event, whatever the endpoint subscribes to. |
redeliver(OutboundDelivery $delivery) | OutboundDelivery | A new series under the same webhook-id. |
redeliverFailed(OutboundEndpoint $endpoint, ?CarbonInterface $since = null, bool $includeCancelled = false) | RedeliveryResult | Every failed (optionally cancelled) delivery of the endpoint. |
dispatchDue() | int | The sweep — what webhooks:retry runs; the number of jobs dispatched. |
deliveries(?OutboundEndpoint $endpoint = null) | Builder<OutboundDelivery> | The outbound log, as a query. |
attempts(OutboundDelivery $delivery) | Collection<int, OutboundAttempt> | Every attempt of every series, in order. |
for($owner) — a boundary
Webhooks::for($owner) returns OwnerWebhooks with inbound(), outbound(), broadcast(string $eventType, $payload) and owner(). Its sub-accessors refuse another owner’s endpoint or delivery with EndpointNotFoundException, as if it did not exist; create() sets the owner, and find(), endpoints() and deliveries() see only that owner’s rows. The HasWebhooks trait’s webhooks() returns the same handle.
schemes() and handlers() — the registries
| SchemeRegistry | HandlerRegistry |
|---|---|
extend(string $name, class-string|Closure $scheme) | register(string $alias, class-string|Closure $handler) |
forget(string $name) | forget(string $alias) |
names(): list<string> | aliases(): list<string> |
has(string $name): bool | has(string $alias): bool |
resolve(string $name): SignatureScheme | resolve(string $alias): InboundHandler |
^[a-z0-9][a-z0-9_.-]{0,63}$ | ^[a-z0-9][a-z0-9_.-]{0,99}$ |
UnknownSchemeException | UnknownHandlerException |
A scheme closure is a factory (it returns the scheme); a handler closure is the handler. Classes resolve through the container, so constructor injection works. Both registries are process-wide: register in a service provider’s boot() and they hold for every request, every job a queue worker runs and every Octane request. A registration made inside a request or job lasts for the rest of the process too, like Gate::define(). forget() removes a run-time registration; a configured one stays.
The route macro
Route::webhooks(string $prefix = 'webhooks', array $middleware = []): Route mounts the inbound pipeline on a route of your own — see Receiving webhooks.
Data objects
In RoundlyConsulting\Webhooks\DataTransferObjects, all final readonly:
| Class | Fields and methods |
|---|---|
CreatedInboundEndpoint | endpoint, url, secret — the secret is redacted in print_r, var_dump and json_encode. |
CreatedOutboundEndpoint | endpoint, secret (redacted). |
RotatedSecret | secret (redacted), graceEndsAt (CarbonImmutable). |
InboundEndpointOptions | ?rateLimitPerMinute, ?maxPayloadBytes, ?contentTypes, ?toleranceSeconds, ?schemeOptions |
InboundEndpointChanges | ?name, ?handler, ?scheme, ?options, list<string> clear |
OutboundEndpointOptions | ?rateLimitPerMinute, ?schemeOptions, ?description |
OutboundEndpointChanges | ?url, ?events, ?name, ?headers, ?scheme, ?options, list<string> clear |
InboundWebhook | deliveryId, endpoint, ?type, payload, headers, receivedAt, isReplay, run, attempt; get(), header(), owner(), isFirstAttempt() |
IncomingWebhook | headers (lower-cased, first value), body, receivedAt, endpointKey, toleranceSeconds, schemeOptions, ?request; header() |
VerifiedWebhook | ?deliveryId, ?timestamp, ?eventType, idIsSigned, slot, replayProtected = false, ?commit; committed() |
OutgoingWebhook | id (msg_…), timestamp, body, eventType |
SignatureHeaders | headers (array<string, string>) |
SanitizeContext | direction, endpoint, ?eventType |
PruneOptions · PruneResult | dryRun = false, ?now · inboundPayloads, outboundBodies, attemptBodies, inboundDeliveries, outboundDeliveries, attempts, expiredSecrets, endpoints, dryRun; total() |
RedeliveryResult | queued, skippedPruned |
DoctorFinding | level, check, ?detail; message(), error(), warning() |
SignedRequestData | uri, headers, body; server() |
Enums
In RoundlyConsulting\Webhooks\Enums, string-backed, with translated labels (webhooks::enums.*) and the enums-for-laravel helpers — label(), labels(), values(), options(), fromLabel(), is(). Values are the snake_case of the case name:
| Enum | Values |
|---|---|
InboundStatus | received, queued, processing, processed, failed |
OutboundStatus | pending, sending, retrying, succeeded, failed, cancelled |
RejectionReason | not_found … sanitizer_failed — status(): int, publicCode(): string (endpoint_disabled reads not_found) |
AttemptError | http_status, redirect_not_followed, timeout, connection, tls, unresolved, blocked, cannot_pin, too_many_redirects, redirect_downgrade, signing, worker_lost, unexpected |
DisableReason | consecutive_failures, gone, manual |
DuplicateResponse | accept, conflict |
SecretSlot | current, previous |
SecretEncoding | standard_webhooks, raw |
Direction | inbound, outbound |
FindingLevel | error, warning |
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.