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

Managing endpoints

Both directions share one management API on their sub-accessor — Webhooks::inbound() and Webhooks::outbound() for the whole app, Webhooks::for($owner)->inbound() and ->outbound() for one owner:

MethodInboundOutbound
find(int|string $id)InboundEndpointOutboundEndpoint — unknown, soft-deleted or another owner’s id: EndpointNotFoundException
endpoints()Collection<InboundEndpoint>Collection<OutboundEndpoint> — ordered by id, owner eager-loaded
update($endpoint, $changes)InboundEndpointChangesOutboundEndpointChanges
enable($endpoint)Clears disabled_at and disabled_reasonThe same, and consecutive_failures back to 0
disable($endpoint)disabled_reason = manual; the URL answers 404EndpointDisabled(manual); pending and retrying deliveries cancelled
delete($endpoint)Soft delete; secrets wiped at onceSoft delete; secrets, URL and custom headers wiped at once; pending deliveries cancelled

Inbound

$endpoint = Webhooks::for($team)->inbound()->find($id);   // another owner's id: EndpointNotFoundException
Webhooks::for($team)->inbound()->endpoints();               // every endpoint of $team, owner eager-loaded

Webhooks::inbound()->update($endpoint, new InboundEndpointChanges(
    name: 'Billing provider',
    handler: 'billing',
    options: new InboundEndpointOptions(rateLimitPerMinute: 120, toleranceSeconds: 60),
));
Webhooks::inbound()->update($endpoint, new InboundEndpointChanges(clear: ['rate_limit_per_minute', 'tolerance_seconds']));   // back to the config defaults

Webhooks::inbound()->disable($endpoint);   // the URL answers 404 until it is enabled again
Webhooks::inbound()->enable($endpoint);
Webhooks::inbound()->delete($endpoint);    // secrets wiped at once, the URL answers 404

InboundEndpointChanges(?string $name, ?string $handler, ?string $scheme, ?InboundEndpointOptions $options, list<string> $clear = []) — only the fields you give change. clear sets nullable fields back to null, so the config default applies: name, handler, rate_limit_per_minute, max_payload_bytes, allowed_content_types, tolerance_seconds, scheme_options. Any other name throws InvalidEndpointException.

Outbound

$endpoint = Webhooks::for($team)->outbound()->find($id);

Webhooks::outbound()->update($endpoint, new OutboundEndpointChanges(
    url: $newUrl,                                    // judged by the SSRF guard again
    events: ['invoice.*', 'customer.created'],
    headers: ['X-Tenant' => 'acme'],                 // replaces the custom headers
    options: new OutboundEndpointOptions(rateLimitPerMinute: 10, description: 'ERP sync'),
));
Webhooks::outbound()->update($endpoint, new OutboundEndpointChanges(clear: ['headers', 'rate_limit_per_minute']));

Webhooks::outbound()->disable($endpoint);   // EndpointDisabled (manual); pending deliveries are cancelled
Webhooks::outbound()->enable($endpoint);    // the failure counter starts again from 0
Webhooks::outbound()->delete($endpoint);    // secrets, URL and custom headers wiped at once

OutboundEndpointChanges(?string $url, ?array $events, ?string $name, ?array $headers, ?string $scheme, ?OutboundEndpointOptions $options, list<string> $clear = []). A new URL is judged like a new endpoint’s (DestinationBlockedException); new headers are validated and replace the old set (an empty array clears them). clear accepts name, description, headers, rate_limit_per_minute and scheme_options. From the console: webhooks:enable {inbound|outbound} {endpoint} and webhooks:endpoints.

The owner trait

Put Concerns\HasWebhooks on any model that owns endpoints — a team, an account, a user. No tenancy is assumed, and endpoints without an owner work too:

use RoundlyConsulting\Webhooks\Concerns\HasWebhooks;

final class Team extends Model
{
    use HasWebhooks;
}
$team->webhooks()->outbound()->create($url, events: ['invoice.*']);   // = Webhooks::for($team)
$team->outboundWebhookEndpoints()->where('enabled', true)->get();        // MorphMany relations
$team->inboundWebhookEndpoints()->count();
$team->broadcastWebhook('invoice.paid', ['invoice' => 42]);
MethodReturns
inboundWebhookEndpoints()MorphMany<InboundEndpoint>
outboundWebhookEndpoints()MorphMany<OutboundEndpoint>
webhooks()OwnerWebhooks — the same handle as Webhooks::for($this)
broadcastWebhook(string $eventType, array|Arrayable|JsonSerializable $payload)Collection<OutboundDelivery> — Webhooks::for($this)->broadcast(…)

The trait goes through the manager, so Webhooks::fake() records its broadcasts.

Option limits

InboundEndpointOptions and OutboundEndpointOptions are validated on create and update; a value out of range throws InvalidEndpointException naming the option:

OptionLimit
rateLimitPerMinuteBoth directions: 1 – 1 000 000
maxPayloadBytesInbound: 1 – inbound.max_payload_bytes_ceiling (1 MB)
contentTypesInbound: a non-empty list of type/subtype (* allowed in the subtype), lower-cased
toleranceSecondsInbound: 1 – 3 600
schemeOptionsBoth: names ^[a-z0-9_]{1,64}$, string values ≤ 255 bytes
descriptionOutbound: ≤ 1 000 characters
nameBoth, on create and update: ≤ 191 characters
Imported inbound secret1 – 1 024 bytes, no control characters

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.