NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Webhooks for Laravel

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

MethodReturnsDoes
send(OutboundEndpoint $endpoint, string $eventType, array|Arrayable|JsonSerializable $payload, ?string $id = null)OutboundDeliveryQueue 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\OwnerWebhooksA handle scoped to one owner: inbound(), outbound(), broadcast(), owner().
inbound()Accessors\InboundWebhooksReceiving: endpoints, the pipeline, verification, replay, the log.
outbound()Accessors\OutboundWebhooksSending: endpoints, send, ping, redelivery, the sweep, the log.
schemes()Accessors\SchemeRegistryThe signature-scheme registry.
handlers()Accessors\HandlerRegistryThe inbound handler registry.
prune(?PruneOptions $options = null)PruneResultRetention — what webhooks:prune runs.
Webhooks::fake()Testing\WebhooksFakeFacade 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

MethodReturnsDoes
create(?string $handler = null, string $scheme = 'default', ?string $name = null, ?string $secret = null, ?InboundEndpointOptions $options = null, ?Model $owner = null)CreatedInboundEndpointA new endpoint: endpoint, url, secret (shown once). Unknown handler or scheme throws.
update(InboundEndpoint $endpoint, InboundEndpointChanges $changes)InboundEndpointName, handler, scheme, options; clear resets nullable fields.
enable($endpoint) · disable($endpoint)InboundEndpointA disabled endpoint answers 404 like an unknown one.
delete(InboundEndpoint $endpoint)voidSoft delete; the URL answers 404 and the secrets are wiped at once.
rotateSecret(InboundEndpoint $endpoint, ?CarbonInterface $grace = null, ?string $secret = null)RotatedSecretA new (or imported) secret; the old one verifies until the grace ends.
find(int|string $id) · endpoints()InboundEndpoint · CollectionScoped handles see only their owner’s endpoints (EndpointNotFoundException).
receive(string $key, Request $request)ResponseThe whole inbound pipeline — what the route runs.
verify(InboundEndpoint $endpoint, Request $request)VerifiedWebhookThe scheme only: no size check, no dedupe, nothing stored (an RFC 9421 nonce is recorded at once).
replay(InboundDelivery $delivery)InboundDeliveryRun 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

MethodReturnsDoes
create(string $url, array $events = ['*'], ?string $name = null, array $headers = [], string $scheme = 'default', ?OutboundEndpointOptions $options = null, ?Model $owner = null)CreatedOutboundEndpointA new endpoint; the URL is judged by the SSRF guard. endpoint, secret (shown once).
update(OutboundEndpoint $endpoint, OutboundEndpointChanges $changes)OutboundEndpointA new URL is judged again; new headers replace the old set.
enable($endpoint) · disable($endpoint)OutboundEndpointDisable fires EndpointDisabled(manual) and cancels pending deliveries; enable resets the failure counter.
delete(OutboundEndpoint $endpoint)voidSoft delete; pending deliveries cancelled; secrets, URL and custom headers wiped at once.
rotateSecret(OutboundEndpoint $endpoint, ?CarbonInterface $grace = null)RotatedSecretBoth secrets sign during the grace.
find(int|string $id) · endpoints()OutboundEndpoint · CollectionThe endpoints, owner eager-loaded.
send($endpoint, string $eventType, $payload, ?string $id = null)OutboundDeliveryThe same as Webhooks::send().
ping(OutboundEndpoint $endpoint)OutboundDeliveryA webhook.ping test event, whatever the endpoint subscribes to.
redeliver(OutboundDelivery $delivery)OutboundDeliveryA new series under the same webhook-id.
redeliverFailed(OutboundEndpoint $endpoint, ?CarbonInterface $since = null, bool $includeCancelled = false)RedeliveryResultEvery failed (optionally cancelled) delivery of the endpoint.
dispatchDue()intThe 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

SchemeRegistryHandlerRegistry
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): boolhas(string $alias): bool
resolve(string $name): SignatureSchemeresolve(string $alias): InboundHandler
^[a-z0-9][a-z0-9_.-]{0,63}$^[a-z0-9][a-z0-9_.-]{0,99}$
UnknownSchemeExceptionUnknownHandlerException

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:

ClassFields and methods
CreatedInboundEndpointendpoint, url, secret — the secret is redacted in print_r, var_dump and json_encode.
CreatedOutboundEndpointendpoint, secret (redacted).
RotatedSecretsecret (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
InboundWebhookdeliveryId, endpoint, ?type, payload, headers, receivedAt, isReplay, run, attempt; get(), header(), owner(), isFirstAttempt()
IncomingWebhookheaders (lower-cased, first value), body, receivedAt, endpointKey, toleranceSeconds, schemeOptions, ?request; header()
VerifiedWebhook?deliveryId, ?timestamp, ?eventType, idIsSigned, slot, replayProtected = false, ?commit; committed()
OutgoingWebhookid (msg_…), timestamp, body, eventType
SignatureHeadersheaders (array<string, string>)
SanitizeContextdirection, endpoint, ?eventType
PruneOptions · PruneResultdryRun = false, ?now · inboundPayloads, outboundBodies, attemptBodies, inboundDeliveries, outboundDeliveries, attempts, expiredSecrets, endpoints, dryRun; total()
RedeliveryResultqueued, skippedPruned
DoctorFindinglevel, check, ?detail; message(), error(), warning()
SignedRequestDatauri, 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:

EnumValues
InboundStatusreceived, queued, processing, processed, failed
OutboundStatuspending, sending, retrying, succeeded, failed, cancelled
RejectionReasonnot_found … sanitizer_failed — status(): int, publicCode(): string (endpoint_disabled reads not_found)
AttemptErrorhttp_status, redirect_not_followed, timeout, connection, tls, unresolved, blocked, cannot_pin, too_many_redirects, redirect_downgrade, signing, worker_lost, unexpected
DisableReasonconsecutive_failures, gone, manual
DuplicateResponseaccept, conflict
SecretSlotcurrent, previous
SecretEncodingstandard_webhooks, raw
Directioninbound, outbound
FindingLevelerror, 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 crypto

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