Handlers and processing
An endpoint stores a handler alias, never a class name. Bind it in config/webhooks.php ('handlers' => ['billing' => BillingWebhookHandler::class]) or in a service provider’s boot():
// AppServiceProvider::boot() — lasts for the whole process: every request, every job a queue
// worker runs, every Octane request
Webhooks::handlers()->register('billing', BillingWebhookHandler::class);
// a closure works too
Webhooks::handlers()->register('audit', function (InboundWebhook $webhook): void {
logger()->info('webhook', ['id' => $webhook->deliveryId, 'type' => $webhook->type]);
});An alias matches ^[a-z0-9][a-z0-9_.-]{0,99}$; a handler class resolves through the container. An endpoint without a handler still stores, queues and fires WebhookReceived — listen to the event instead.
Writing a handler
final class BillingWebhookHandler implements InboundHandler
{
public function handle(InboundWebhook $webhook): void
{
// $webhook->deliveryId public id — your own idempotency key for side effects that must never repeat
// $webhook->type the scheme's event type, else the payload's "type"
// $webhook->payload sanitized, untrusted array — validate it
// $webhook->headers the stored allowlist (inbound.store_headers)
// $webhook->endpoint the InboundEndpoint
// $webhook->isReplay, ->run, ->attempt, ->receivedAt
$invoice = $webhook->get('data.invoice.id'); // dot path into the payload, or null
$agent = $webhook->header('User-Agent'); // a stored header, case-insensitive
$team = $webhook->owner(); // the endpoint's owner, or null
}
}The payload is decoded to arrays only, sanitized and still untrusted — validate it before use, and never use a value as a class name, file path, URL or query fragment without checking it against an allowlist:
$data = Validator::make($webhook->payload, [
'data.invoice.id' => ['required', 'integer'],
])->validate();Queued, once per delivery
- ProcessInboundDelivery runs on webhooks.queue.inbound: WebhookReceived fires first, then the handler. Tries inbound.handler.tries (3), backoff [10, 60, 300], timeout 60 s.
- Sentinel’s idempotent job middleware makes a double dispatch, a sender retry or a sweep re-queue run the handler once.
- After the last failed try the delivery is failed with last_error_code handler_failed, a safe summary — the exception’s short name and a short code such as QueryException (23000), never its message, which may quote the payload — and WebhookProcessingFailed. The full exception reaches your log through the failed job.
- A delivery whose endpoint was deleted before it ran fails at once (endpoint_deleted).
- WebhookReceived fires on every attempt of a run: $webhook->attempt and isFirstAttempt() tell them apart.
- Statuses: received → queued → processing → processed | failed. Jobs carry Horizon tags (webhooks, webhooks:inbound, webhooks:delivery:{id}, webhooks:run:{n}).
Exactly once holds for every normal path. A worker killed after the handler returned but before Sentinel recorded completion lets the lease expire and the handler run again — use $webhook->deliveryId as your own idempotency key for side effects that must never repeat.
Replay
Webhooks::inbound()->replay($delivery); // runs + 1, isReplay = true, same stored payloadA replay opens a new run on the stored payload, flagged isReplay. A pruned payload throws PayloadPrunedException; a delivery being processed throws DeliveryInProgressException — unless its last attempt started longer ago than the handler timeout + 30 s + the longest backoff + 90 s (its worker died), which can then be replayed. From the console: webhooks:replay {public id}.
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.