Sending webhooks
An outbound endpoint is a receiver URL your customer gives you, the event types it subscribes to and a secret the receiver verifies with:
use RoundlyConsulting\Webhooks\DataTransferObjects\OutboundEndpointOptions;
use RoundlyConsulting\Webhooks\Facades\Webhooks;
// $url — the receiver address your customer typed into your settings page
$created = Webhooks::for($team)->outbound()->create(
$url,
events: ['invoice.*', 'customer.created'], // exact, prefix.*, or *
name: 'ERP',
headers: ['X-Tenant' => 'acme'], // custom headers (validated)
options: new OutboundEndpointOptions(rateLimitPerMinute: 30, description: 'ledger sync'),
);
$created->secret; // whsec_… — hand it to the receiver onceThe URL is judged by the SSRF guard (see Outbound safety) and stored encrypted — it may carry a token; url_host stays readable for listings, and resources render the URL as scheme://host[:port]/…. A refused or unresolvable URL throws DestinationBlockedException.
Subscriptions
events is a list of 1–100 entries: * for every type, an exact type such as invoice.paid, or a prefix + .* — invoice.* matches invoice.paid and invoice.line.added, but not invoice itself. Types match ^[a-z0-9_]+(\.[a-z0-9_]+)*$, at most 100 characters. $endpoint->subscribesTo('invoice.paid') answers the same question in your code.
Custom headers
| Rule | Meaning |
|---|---|
| Names | RFC 9110 tokens; no CR, LF or NUL; at most 20 headers and 8 KB; stored encrypted. |
| Never settable | content-length, content-type, transfer-encoding, connection, te, upgrade, cookie, content-digest, keep-alive, expect, trailer, proxy-*, webhook-*, signature* |
| Only when allowed | host and authorization, through outbound.headers.allowed_overrides — host only with the SSRF guard off: the guard always sends the vetted host, so under it a Host header is refused rather than silently replaced. |
| On every attempt | The rules are applied again: a header whose override was taken off the list is dropped, never sent. |
| Redirects | Custom headers go to the first origin only; another origin gets only the signature headers. |
send and broadcast
Webhooks::send($endpoint, 'invoice.paid', ['invoice' => 42]); // array, Arrayable or JsonSerializable
Webhooks::send($endpoint, 'invoice.paid', $payload, id: 'invoice-42-paid'); // idempotent: a repeat returns the first delivery
Webhooks::for($team)->broadcast('invoice.paid', $payload); // every enabled, subscribed endpoint of $team
Webhooks::broadcast('system.maintenance', $payload); // owner-less endpoints only
$team->broadcastWebhook('invoice.paid', $payload); // the HasWebhooks trait
Webhooks::outbound()->ping($endpoint); // a webhook.ping test event- Idempotent send — id: is yours (an event id, invoice-42-paid). The webhook-id is derived from the endpoint and the id, and a repeat with the same id returns the delivery queued the first time and queues nothing — for a listener that may run twice. The first body wins.
- send() to a disabled or unsubscribed endpoint throws EndpointNotAcceptingException (reason(): disabled or not_subscribed); broadcast() skips them.
- Event types are validated (InvalidEventTypeException); with outbound.event_types non-empty, only those are allowed.
- The delivery is inserted and its first attempt dispatched after the current transaction commits: a rolled-back transaction sends nothing.
The envelope
The body is built once — {"type", "timestamp" (RFC 3339 UTC), "data"}, or the payload as is with outbound.envelope off — as JSON with unescaped slashes and unicode, stored encrypted and byte-identical on every attempt. Over outbound.max_payload_bytes (256 KB) it throws PayloadTooLargeException.
{"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}}Events that broadcast themselves
Put BroadcastsAsWebhook on one of your own events and dispatching it broadcasts it — through the manager, so Webhooks::fake() records it:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use RoundlyConsulting\Webhooks\Contracts\BroadcastsAsWebhook;
final class InvoicePaid implements BroadcastsAsWebhook
{
use Dispatchable;
public function __construct(public Invoice $invoice) {}
public function webhookType(): string { return 'invoice.paid'; }
public function webhookPayload(): array { return ['invoice' => $this->invoice->id]; }
public function webhookOwner(): ?Model { return $this->invoice->team; } // null: app-level endpoints
}
InvoicePaid::dispatch($invoice); // or event(new InvoicePaid($invoice))Ping
A test button for your customers. ping() sends webhook.ping whatever the endpoint subscribes to and whether or not outbound.event_types lists it — otherwise a normal delivery: signed, retried, logged. A disabled endpoint throws EndpointNotAcceptingException. From the console: webhooks:ping {endpoint}.
Webhooks::outbound()->ping($endpoint);
// {"type":"webhook.ping","timestamp":"…","data":{"ping":true}}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.