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

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

KeyEnvDefaultPurpose
key_typeWEBHOOKS_KEY_TYPEbigintbigint, uuid or ulid — the key type of the host models that own endpoints (the owner morph columns). Set it before migrating.
primary_key_typeWEBHOOKS_PRIMARY_KEY_TYPEbigintbigint, uuid (UUIDv7) or ulid — the package tables’ own ids and foreign keys. Before migrating. Wire ids are ULIDs either way.
models.*—the packaged modelsinbound_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—WebhookOwnerResourceHow an endpoint’s owner renders in the JSON resources — an instantiable JsonResource subclass.

Queue

KeyEnvDefaultPurpose
queue.connectionWEBHOOKS_QUEUE_CONNECTIONblank = defaultThe connection every job of the package uses.
queue.queueWEBHOOKS_QUEUE_NAMEblank = the connection’s defaultThe fallback queue for both directions.
queue.inboundWEBHOOKS_INBOUND_QUEUEblank = queue.queueThe queue of ProcessInboundDelivery.
queue.outboundWEBHOOKS_OUTBOUND_QUEUEblank = queue.queueThe queue of DeliverWebhook.
queue.max_delay—9001–43 200 s — the longest delayed job; a longer retry waits for the webhooks:retry sweep (SQS cannot delay over 900 s).

Inbound

KeyEnvDefaultPurpose
inbound.route.enabledWEBHOOKS_INBOUND_ROUTEonThe package’s POST {prefix}/{key} route; off → mount your own.
inbound.route.prefix—webhooksThe route prefix (slashes trimmed).
inbound.route.domain—nullA route domain.
inbound.route.middleware—[]Middleware names — never web: a sender has no session or CSRF token.
inbound.route.url_baseWEBHOOKS_INBOUND_URLnull = route()Base of the endpoint URLs shown to you ({url_base}/{key}), e.g. behind a proxy.
inbound.tolerance—3001–3 600 s — how far a signed timestamp may be from now, past or future.
inbound.max_payload_bytes—262144Body size limit (256 KB), 1 – max_payload_bytes_ceiling.
inbound.max_payload_bytes_ceiling—10485761 – 67 108 864 — the most a per-endpoint override may allow (1 MB).
inbound.content_types—application/json, application/*+jsonAccepted Content-Type media types (* allowed in the subtype, parameters ignored).
inbound.on_duplicate—acceptaccept answers a repeat 200 {"status":"duplicate"}; conflict answers 409.
inbound.accept_idempotency_header—onUse Idempotency-Key when the scheme carries no delivery id.
inbound.dedupe_ttl—60480060 – 2 592 000 s — how long delivery ids (and body digests of unsigned ids) are remembered (7 days).
inbound.rate_limit.per_minute—600Verified deliveries per endpoint per minute.
inbound.rate_limit.rejected_per_minute—60Rejected requests per endpoint and client IP per minute.
inbound.rate_limit.cache_store—null = default storeThe limiters’ cache store — must be shared, never array.
inbound.store_headers—content-type, user-agent, webhook-id, webhook-timestampHeaders stored with a delivery; signature, credential and cookie headers are dropped whatever the list says.
inbound.requeue_after—60A stored delivery not queued after this many seconds is queued by the sweep.
inbound.handler.tries—31–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—601–86 400 s — the processing job’s timeout.

Outbound

KeyEnvDefaultPurpose
outbound.allow_httpWEBHOOKS_ALLOW_HTTPoffhttp as well as https — local development only.
outbound.verify_on_save—onResolve and vet the URL on create and update; off = every rule but the DNS lookup.
outbound.envelope—onWrap 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—262144Encoded body limit (PayloadTooLargeException).
outbound.max_response_bytes—40961 – 1 048 576 — how much of a receiver’s answer is read and stored.
outbound.timeouts.connect—51–60 s — connect timeout.
outbound.timeouts.total—10connect – 300 s — one budget across every redirect hop; DeliverWebhook::$timeout is this + 20.
outbound.max_attempts—81–25 attempts per delivery series.
outbound.retry.base—60delay(n) = min(base · multiplier^(n-1), cap).
outbound.retry.multiplier—41–100.
outbound.retry.cap—32400The longest computed delay (9 h).
outbound.retry.jitter_permille—1000–500 — random spread (± 10 %).
outbound.retry.max_retry_after—86400The longest Retry-After honoured.
outbound.retry.schedule—nullExplicit delays instead of the formula ([60, 300, 3600]); past its end the last value repeats.
outbound.rate_limit.per_minute—60Requests per endpoint per minute (an endpoint may override).
outbound.requeue_after—120A due delivery whose job is this late is dispatched again; each further copy waits twice as long (up to an hour).
outbound.sweep_batch—10001 – 100 000 — the most outbound jobs one webhooks:retry run dispatches.
outbound.disable_after—5Deliveries that ended failed in a row before the endpoint is disabled.
outbound.disable_on_gone—onA 410 Gone answer disables the endpoint at once.
outbound.follow_redirects—offFollow 307/308 (each hop vetted again).
outbound.max_redirects—30–10.
outbound.headers.allowed_overrides—[]Reserved custom headers an endpoint may still set: host (only with ssrf.enabled off), authorization.
outbound.user_agentWEBHOOKS_USER_AGENTblank = "{app.name} Webhooks"The User-Agent receivers see (Laravel Webhooks without an app name).

Outbound safety (SSRF)

KeyEnvDefaultPurpose
ssrf.enabledWEBHOOKS_SSRF_GUARDonThe guard; off only behind a filtering egress proxy you trust.
ssrf.block_app_hosts—onRefuse 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 = anyA list of ports 1–65 535.

Secrets, schemes and handlers

KeyEnvDefaultPurpose
secrets.bytes—3224–64 random bytes in a generated secret (whsec_ + base64).
secrets.grace_seconds—86400Default grace of a rotated-out secret, 0 – max_grace_seconds.
secrets.max_grace_seconds—604800The longest grace rotateSecret() accepts (0 – 31 536 000).
schemes—default ⇒ DefaultScheme, rfc9421 ⇒ Rfc9421SchemeName ⇒ a SignatureScheme class; add yours here or with Webhooks::schemes()->extend().
rfc9421.profile—defaultThe Sentinel signature profile inbound RFC 9421 endpoints verify against (an endpoint’s scheme_options.profile wins).
rfc9421.outbound_key_id—nullThe 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

KeyEnvDefaultPurpose
sanitizer.class—KeyRedactingSanitizerA PayloadSanitizer class run on every inbound payload before it is stored.
sanitizer.redact_keys—password, secret, token, api_key, authorization, card_number, cvc, ssnKey names the default sanitizer redacts (case-insensitive).
sanitizer.replacement—[redacted]The value they get.
payloads.encrypt—onEncrypt stored payloads and bodies with the app key.
payloads.retention_days—301–36 500 — payloads, bodies and response excerpts are cleared after this.
payloads.delivery_retention_days—901–36 500 or null = keep — delivery and attempt rows (and deleted endpoints nothing points at) are deleted after this.
schedule.enabled—onThe provider schedules webhooks:retry and webhooks:prune.
schedule.retry—everyMinuteeveryMinute, everyTwoMinutes or everyFiveMinutes.
schedule.prune—dailyhourly, daily or weekly.

Per-endpoint overrides

Some settings can be overridden per endpoint through the options objects (limits in Managing endpoints):

SettingInboundEndpointOptionsOutboundEndpointOptions
Rate limit per minuterateLimitPerMinuterateLimitPerMinute
Body sizemaxPayloadBytes—
Content typescontentTypes—
Timestamp tolerancetoleranceSeconds—
Scheme optionsschemeOptions (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=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.