All packages
Webhooks for Laravel
Configuration
The package works with zero configuration. Publish config/webhooks.php with --tag=webhooks-config to change it (shown without its comments):
use RoundlyConsulting\Webhooks\Http\Resources\WebhookOwnerResource;
use RoundlyConsulting\Webhooks\Models\InboundDelivery;
use RoundlyConsulting\Webhooks\Models\InboundEndpoint;
use RoundlyConsulting\Webhooks\Models\OutboundAttempt;
use RoundlyConsulting\Webhooks\Models\OutboundDelivery;
use RoundlyConsulting\Webhooks\Models\OutboundEndpoint;
use RoundlyConsulting\Webhooks\Schemes\DefaultScheme;
use RoundlyConsulting\Webhooks\Schemes\Rfc9421Scheme;
use RoundlyConsulting\Webhooks\Support\KeyRedactingSanitizer;
return [
'key_type' => env('WEBHOOKS_KEY_TYPE', 'bigint'),
'primary_key_type' => env('WEBHOOKS_PRIMARY_KEY_TYPE', 'bigint'),
'models' => [
'inbound_endpoint' => InboundEndpoint::class,
'inbound_delivery' => InboundDelivery::class,
'outbound_endpoint' => OutboundEndpoint::class,
'outbound_delivery' => OutboundDelivery::class,
'outbound_attempt' => OutboundAttempt::class,
],
'tables' => [
'inbound_endpoints' => 'webhook_inbound_endpoints',
'inbound_deliveries' => 'webhook_inbound_deliveries',
'outbound_endpoints' => 'webhook_outbound_endpoints',
'outbound_deliveries' => 'webhook_outbound_deliveries',
'outbound_attempts' => 'webhook_outbound_attempts',
],
'resources' => [
'owner' => WebhookOwnerResource::class,
],
'queue' => [
'connection' => env('WEBHOOKS_QUEUE_CONNECTION'),
'queue' => env('WEBHOOKS_QUEUE_NAME'),
'inbound' => env('WEBHOOKS_INBOUND_QUEUE'),
'outbound' => env('WEBHOOKS_OUTBOUND_QUEUE'),
'max_delay' => 900,
],
'inbound' => [
'route' => [
// POST {prefix}/{key}. Off: mount your own route calling Webhooks::inbound()->receive().
'enabled' => env('WEBHOOKS_INBOUND_ROUTE', true),
'prefix' => 'webhooks',
'domain' => null,
// Never `web`: a sender has no session or CSRF token.
'middleware' => [],
// The base the endpoint URL is built on (e.g. behind a proxy); blank = route().
'url_base' => env('WEBHOOKS_INBOUND_URL'),
],
'tolerance' => 300,
'max_payload_bytes' => 262144,
'max_payload_bytes_ceiling' => 1048576,
'content_types' => ['application/json', 'application/*+json'],
'on_duplicate' => 'accept',
'accept_idempotency_header' => true,
'dedupe_ttl' => 604800,
'rate_limit' => [
'per_minute' => 600,
'rejected_per_minute' => 60,
'cache_store' => null,
],
'store_headers' => ['content-type', 'user-agent', 'webhook-id', 'webhook-timestamp'],
'requeue_after' => 60,
'handler' => [
'tries' => 3,
'backoff' => [10, 60, 300],
'timeout' => 60,
],
],
'outbound' => [
'allow_http' => env('WEBHOOKS_ALLOW_HTTP', false),
'verify_on_save' => true,
'envelope' => true,
'event_types' => [],
'max_payload_bytes' => 262144,
'max_response_bytes' => 4096,
'timeouts' => [
'connect' => 5,
'total' => 10,
],
'max_attempts' => 8,
'retry' => [
// delay(n) = min(base * multiplier^(n-1), cap) seconds, then ± jitter_permille.
'base' => 60,
'multiplier' => 4,
'cap' => 32400,
'jitter_permille' => 100,
'max_retry_after' => 86400,
'schedule' => null,
],
'rate_limit' => [
'per_minute' => 60,
],
'requeue_after' => 120,
'sweep_batch' => 1000,
'disable_after' => 5,
'disable_on_gone' => true,
'follow_redirects' => false,
'max_redirects' => 3,
'headers' => [
'allowed_overrides' => [],
],
// The User-Agent your receivers see. Blank = "{app.name} Webhooks".
'user_agent' => env('WEBHOOKS_USER_AGENT'),
],
'ssrf' => [
// Off only behind a filtering egress proxy you trust (webhooks:doctor warns).
'enabled' => env('WEBHOOKS_SSRF_GUARD', true),
'block_app_hosts' => true,
'app_hosts' => [],
'allowed_hosts' => [],
'blocked_hosts' => [],
'allowed_ranges' => [],
'blocked_ranges' => [],
'allowed_ports' => null,
],
'secrets' => [
'bytes' => 32,
'grace_seconds' => 86400,
'max_grace_seconds' => 604800,
],
'schemes' => [
'default' => DefaultScheme::class,
'rfc9421' => Rfc9421Scheme::class,
],
'rfc9421' => [
'profile' => 'default',
'outbound_key_id' => null,
],
'handlers' => [],
'sanitizer' => [
'class' => KeyRedactingSanitizer::class,
'redact_keys' => ['password', 'secret', 'token', 'api_key', 'authorization', 'card_number', 'cvc', 'ssn'],
'replacement' => '[redacted]',
],
'payloads' => [
'encrypt' => true,
'retention_days' => 30,
'delivery_retention_days' => 90,
],
'schedule' => [
'enabled' => true,
'retry' => 'everyMinute',
'prune' => 'daily',
],
];Strict reading
- Every value is read strictly: a typo or an out-of-range value throws the toolkit’s InvalidConfigurationException naming the key.
- A class-valued key — schemes.*, handlers.*, sanitizer.class — that names no instantiable implementation of its contract throws InvalidWebhooksConfigurationException, also naming the key.
- A blank value (KEY=) means not set, and the default applies. Switches accept true/false, 1/0, on/off and yes/no.
- Integer lists (handler.backoff, retry.schedule, ssrf.allowed_ports) also accept a comma-separated string ("60,300,3600").
Keys, models and tables
| Key | Env | Default | Purpose |
|---|---|---|---|
key_type | WEBHOOKS_KEY_TYPE | bigint | bigint, uuid or ulid — the key type of the host models that own endpoints (the owner morph columns). Set it before migrating. |
primary_key_type | WEBHOOKS_PRIMARY_KEY_TYPE | bigint | bigint, uuid (UUIDv7) or ulid — the package tables’ own ids and foreign keys. Before migrating. Wire ids are ULIDs either way. |
models.* | — | the packaged models | inbound_endpoint, inbound_delivery, outbound_endpoint, outbound_delivery, outbound_attempt — a replacement must extend the packaged model. |
tables.* | — | webhook_inbound_endpoints, … | The five table names; each must match ^[A-Za-z_][A-Za-z0-9_.]{0,63}$. |
resources.owner | — | WebhookOwnerResource | How an endpoint’s owner renders in the JSON resources — an instantiable JsonResource subclass. |
Queue
| Key | Env | Default | Purpose |
|---|---|---|---|
queue.connection | WEBHOOKS_QUEUE_CONNECTION | blank = default | The connection every job of the package uses. |
queue.queue | WEBHOOKS_QUEUE_NAME | blank = the connection’s default | The fallback queue for both directions. |
queue.inbound | WEBHOOKS_INBOUND_QUEUE | blank = queue.queue | The queue of ProcessInboundDelivery. |
queue.outbound | WEBHOOKS_OUTBOUND_QUEUE | blank = queue.queue | The queue of DeliverWebhook. |
queue.max_delay | — | 900 | 1–43 200 s — the longest delayed job; a longer retry waits for the webhooks:retry sweep (SQS cannot delay over 900 s). |
Inbound
| Key | Env | Default | Purpose |
|---|---|---|---|
inbound.route.enabled | WEBHOOKS_INBOUND_ROUTE | on | The package’s POST {prefix}/{key} route; off → mount your own. |
inbound.route.prefix | — | webhooks | The route prefix (slashes trimmed). |
inbound.route.domain | — | null | A route domain. |
inbound.route.middleware | — | [] | Middleware names — never web: a sender has no session or CSRF token. |
inbound.route.url_base | WEBHOOKS_INBOUND_URL | null = route() | Base of the endpoint URLs shown to you ({url_base}/{key}), e.g. behind a proxy. |
inbound.tolerance | — | 300 | 1–3 600 s — how far a signed timestamp may be from now, past or future. |
inbound.max_payload_bytes | — | 262144 | Body size limit (256 KB), 1 – max_payload_bytes_ceiling. |
inbound.max_payload_bytes_ceiling | — | 1048576 | 1 – 67 108 864 — the most a per-endpoint override may allow (1 MB). |
inbound.content_types | — | application/json, application/*+json | Accepted Content-Type media types (* allowed in the subtype, parameters ignored). |
inbound.on_duplicate | — | accept | accept answers a repeat 200 {"status":"duplicate"}; conflict answers 409. |
inbound.accept_idempotency_header | — | on | Use Idempotency-Key when the scheme carries no delivery id. |
inbound.dedupe_ttl | — | 604800 | 60 – 2 592 000 s — how long delivery ids (and body digests of unsigned ids) are remembered (7 days). |
inbound.rate_limit.per_minute | — | 600 | Verified deliveries per endpoint per minute. |
inbound.rate_limit.rejected_per_minute | — | 60 | Rejected requests per endpoint and client IP per minute. |
inbound.rate_limit.cache_store | — | null = default store | The limiters’ cache store — must be shared, never array. |
inbound.store_headers | — | content-type, user-agent, webhook-id, webhook-timestamp | Headers stored with a delivery; signature, credential and cookie headers are dropped whatever the list says. |
inbound.requeue_after | — | 60 | A stored delivery not queued after this many seconds is queued by the sweep. |
inbound.handler.tries | — | 3 | 1–100 — ProcessInboundDelivery::$tries. |
inbound.handler.backoff | — | [10, 60, 300] | Seconds between the processing job’s tries (a list, or a comma-separated string). |
inbound.handler.timeout | — | 60 | 1–86 400 s — the processing job’s timeout. |
Outbound
| Key | Env | Default | Purpose |
|---|---|---|---|
outbound.allow_http | WEBHOOKS_ALLOW_HTTP | off | http as well as https — local development only. |
outbound.verify_on_save | — | on | Resolve and vet the URL on create and update; off = every rule but the DNS lookup. |
outbound.envelope | — | on | Wrap payloads in {"type","timestamp","data"}; off = the payload as is. |
outbound.event_types | — | [] | Known types; empty = any valid name, non-empty = only these (InvalidEventTypeException). |
outbound.max_payload_bytes | — | 262144 | Encoded body limit (PayloadTooLargeException). |
outbound.max_response_bytes | — | 4096 | 1 – 1 048 576 — how much of a receiver’s answer is read and stored. |
outbound.timeouts.connect | — | 5 | 1–60 s — connect timeout. |
outbound.timeouts.total | — | 10 | connect – 300 s — one budget across every redirect hop; DeliverWebhook::$timeout is this + 20. |
outbound.max_attempts | — | 8 | 1–25 attempts per delivery series. |
outbound.retry.base | — | 60 | delay(n) = min(base · multiplier^(n-1), cap). |
outbound.retry.multiplier | — | 4 | 1–100. |
outbound.retry.cap | — | 32400 | The longest computed delay (9 h). |
outbound.retry.jitter_permille | — | 100 | 0–500 — random spread (± 10 %). |
outbound.retry.max_retry_after | — | 86400 | The longest Retry-After honoured. |
outbound.retry.schedule | — | null | Explicit delays instead of the formula ([60, 300, 3600]); past its end the last value repeats. |
outbound.rate_limit.per_minute | — | 60 | Requests per endpoint per minute (an endpoint may override). |
outbound.requeue_after | — | 120 | A due delivery whose job is this late is dispatched again; each further copy waits twice as long (up to an hour). |
outbound.sweep_batch | — | 1000 | 1 – 100 000 — the most outbound jobs one webhooks:retry run dispatches. |
outbound.disable_after | — | 5 | Deliveries that ended failed in a row before the endpoint is disabled. |
outbound.disable_on_gone | — | on | A 410 Gone answer disables the endpoint at once. |
outbound.follow_redirects | — | off | Follow 307/308 (each hop vetted again). |
outbound.max_redirects | — | 3 | 0–10. |
outbound.headers.allowed_overrides | — | [] | Reserved custom headers an endpoint may still set: host (only with ssrf.enabled off), authorization. |
outbound.user_agent | WEBHOOKS_USER_AGENT | blank = "{app.name} Webhooks" | The User-Agent receivers see (Laravel Webhooks without an app name). |
Outbound safety (SSRF)
| Key | Env | Default | Purpose |
|---|---|---|---|
ssrf.enabled | WEBHOOKS_SSRF_GUARD | on | The guard; off only behind a filtering egress proxy you trust. |
ssrf.block_app_hosts | — | on | Refuse app.url’s host and ssrf.app_hosts. |
ssrf.app_hosts | — | [] | More names of your own app. |
ssrf.allowed_hosts | — | [] | Exact or *.example.com; non-empty means only these hosts (still resolved and vetted). |
ssrf.blocked_hosts | — | [] | Always refused. |
ssrf.allowed_ranges | — | [] | IPs or CIDRs exempt from the private-network table (127.0.0.1/32 locally). |
ssrf.blocked_ranges | — | [] | IPs or CIDRs always refused — block beats allow. |
ssrf.allowed_ports | — | null = any | A list of ports 1–65 535. |
Secrets, schemes and handlers
| Key | Env | Default | Purpose |
|---|---|---|---|
secrets.bytes | — | 32 | 24–64 random bytes in a generated secret (whsec_ + base64). |
secrets.grace_seconds | — | 86400 | Default grace of a rotated-out secret, 0 – max_grace_seconds. |
secrets.max_grace_seconds | — | 604800 | The longest grace rotateSecret() accepts (0 – 31 536 000). |
schemes | — | default ⇒ DefaultScheme, rfc9421 ⇒ Rfc9421Scheme | Name ⇒ a SignatureScheme class; add yours here or with Webhooks::schemes()->extend(). |
rfc9421.profile | — | default | The Sentinel signature profile inbound RFC 9421 endpoints verify against (an endpoint’s scheme_options.profile wins). |
rfc9421.outbound_key_id | — | null | The Sentinel key outbound RFC 9421 endpoints sign with (an endpoint’s scheme_options.key_id wins). |
handlers | — | [] | Alias ⇒ an InboundHandler class; closures through Webhooks::handlers()->register(). |
Payloads, retention and schedule
| Key | Env | Default | Purpose |
|---|---|---|---|
sanitizer.class | — | KeyRedactingSanitizer | A PayloadSanitizer class run on every inbound payload before it is stored. |
sanitizer.redact_keys | — | password, secret, token, api_key, authorization, card_number, cvc, ssn | Key names the default sanitizer redacts (case-insensitive). |
sanitizer.replacement | — | [redacted] | The value they get. |
payloads.encrypt | — | on | Encrypt stored payloads and bodies with the app key. |
payloads.retention_days | — | 30 | 1–36 500 — payloads, bodies and response excerpts are cleared after this. |
payloads.delivery_retention_days | — | 90 | 1–36 500 or null = keep — delivery and attempt rows (and deleted endpoints nothing points at) are deleted after this. |
schedule.enabled | — | on | The provider schedules webhooks:retry and webhooks:prune. |
schedule.retry | — | everyMinute | everyMinute, everyTwoMinutes or everyFiveMinutes. |
schedule.prune | — | daily | hourly, daily or weekly. |
Per-endpoint overrides
Some settings can be overridden per endpoint through the options objects (limits in Managing endpoints):
| Setting | InboundEndpointOptions | OutboundEndpointOptions |
|---|---|---|
| Rate limit per minute | rateLimitPerMinute | rateLimitPerMinute |
| Body size | maxPayloadBytes | — |
| Content types | contentTypes | — |
| Timestamp tolerance | toleranceSeconds | — |
| Scheme options | schemeOptions (profile, id_header) | schemeOptions (key_id) |
| Description | — | description |
Environment variables
WEBHOOKS_KEY_TYPE=bigint
WEBHOOKS_PRIMARY_KEY_TYPE=bigint
WEBHOOKS_QUEUE_CONNECTION=
WEBHOOKS_QUEUE_NAME=
WEBHOOKS_INBOUND_QUEUE=
WEBHOOKS_OUTBOUND_QUEUE=
WEBHOOKS_INBOUND_ROUTE=true
WEBHOOKS_INBOUND_URL=
WEBHOOKS_ALLOW_HTTP=false
WEBHOOKS_USER_AGENT=
WEBHOOKS_SSRF_GUARD=trueShow 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.