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

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 once

The 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

RuleMeaning
NamesRFC 9110 tokens; no CR, LF or NUL; at most 20 headers and 8 KB; stored encrypted.
Never settablecontent-length, content-type, transfer-encoding, connection, te, upgrade, cookie, content-digest, keep-alive, expect, trailer, proxy-*, webhook-*, signature*
Only when allowedhost 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 attemptThe rules are applied again: a header whose override was taken off the list is dropped, never sent.
RedirectsCustom 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 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.