---
title: "Sending webhooks — Webhooks for Laravel | Roundly"
description: "Outbound endpoints with subscriptions and custom headers, send, idempotent sends, broadcast, events that broadcast themselves, ping and the envelope."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/sending
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# 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:

```php
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

| 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

```php
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.

```json
{"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:

```php
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}.

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
