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:
| Method | Inbound | Outbound |
|---|---|---|
find(int|string $id) | InboundEndpoint | OutboundEndpoint — unknown, soft-deleted or another owner’s id: EndpointNotFoundException |
endpoints() | Collection<InboundEndpoint> | Collection<OutboundEndpoint> — ordered by id, owner eager-loaded |
update($endpoint, $changes) | InboundEndpointChanges | OutboundEndpointChanges |
enable($endpoint) | Clears disabled_at and disabled_reason | The same, and consecutive_failures back to 0 |
disable($endpoint) | disabled_reason = manual; the URL answers 404 | EndpointDisabled(manual); pending and retrying deliveries cancelled |
delete($endpoint) | Soft delete; secrets wiped at once | Soft 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 404InboundEndpointChanges(?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 onceOutboundEndpointChanges(?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]);| Method | Returns |
|---|---|
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:
| Option | Limit |
|---|---|
rateLimitPerMinute | Both directions: 1 – 1 000 000 |
maxPayloadBytes | Inbound: 1 – inbound.max_payload_bytes_ceiling (1 MB) |
contentTypes | Inbound: a non-empty list of type/subtype (* allowed in the subtype), lower-cased |
toleranceSeconds | Inbound: 1 – 3 600 |
schemeOptions | Both: names ^[a-z0-9_]{1,64}$, string values ≤ 255 bytes |
description | Outbound: ≤ 1 000 characters |
name | Both, on create and update: ≤ 191 characters |
| Imported inbound secret | 1 – 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 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.