Configuration reference
Every option is documented inline in config/webhooks.php — publish it with
php artisan vendor:publish --tag=webhooks-config. This page is the map.
Keep only what you change
A published file is merged over the shipped defaults, all the way down, so you can delete everything you are not changing. A four-line file that says what you decided is worth more than a copy of the whole shipped file, which hides it in a thousand lines of defaults you never chose — and the four-line one keeps receiving every option later versions add.
// config/webhooks.php — a complete, valid file
return [
'platform' => [
'catalog' => ['order.shipped', 'invoice.paid'],
'self_service' => ['max_endpoints_per_tenant' => 25],
],
];
Three rules decide what happens to a value you do set:
- A map is merged key by key. Setting
platform.self_service.max_endpoints_per_tenantleavesregistrations_per_minuteand every other sibling at its shipped default. - A list is replaced whole, never appended to.
dashboard.windows,dashboard.middleware,platform.self_service.middleware,platform.catalog,platform.payload_versioning.versions,server.retryable_4xx,client.configs,core.ssrf.allowed_hosts,core.ssrf.blocked_hostsandcore.egress.published_ipsare lists. Writing'windows' => ['7d']gives you exactly one window. This is what you want for an allow-list: you get the entries you named and no others. - Switching something off is spelled
null, not omission. Removing a key means "no opinion" and you get the default back.'max_per_minute' => nullis how you remove a brake.
config:cache?The merge happens while the application boots, so a cached configuration file holds its
result. After upgrading the package, rebuild it — php artisan config:cache — or the
cache keeps serving what the previous version merged.
The section tree
| Section | Gate (default) | Contents |
|---|---|---|
core | always on | signing.scheme, the ssrf policy, the egress IPs this installation publishes + proxy |
server | server (on) — forced on by platform | queue/connection, signing (canonicalize, ed25519), http_verb, timeouts, tries, backoff, no_retry_on_4xx, persistence, ordering, outbox, large_payload, verify_ssl, horizon_tags, request_options |
platform | platform (on) | catalog, validate_payloads, circuit_breaker, rate_limit, test_ping, retention/partitioning, self_service, deliveries (window_days, show_errors), health, payload_versioning |
client | client.enabled (off) | raw_body_capture, per-source configs, expected, delete_after_days |
dashboard | dashboard.enabled (off) — needs platform | route prefix/middleware, source_model, windows, poll_interval, percentiles, metrics.refresh, expose_json_api + api_path, deliveries.window_days, payload (the body's own ability) |
pulse | pulse.enabled (off) | the internal-ops Pulse card |
search | search.enabled (off) | optional Laravel Scout full-text index over the delivery/call logs |
otel | otel.enabled (off) | a dependency-free OpenTelemetry span seam per finished delivery — you bind your own SpanEmitter; the default binding is a no-op, so the flag alone emits nothing |
ui | always on | theme (auto / light / dark) for the package's own full-page layouts, variant, assets, csp_nonce, secondary_surface, page_heading_level, row_action_icons, and deliveries.default_window_days for the operator log |
admin | always on | ability and abilities — the optional per-action check on the operator console, both unset by default |
Three more sit above the layers:
| Key | Default | What it does |
|---|---|---|
database.connection | the app default (WEBHOOKS_DB_CONNECTION) | Pins every model, migration and analytics query to one connection — the side-car topology |
schedule.enabled | true | false registers nothing in the scheduler — see Scheduled maintenance |
models | [] | Your subclasses of the package's models, keyed by the package class — see below |
The layer gates and their environment variables
| Key | Environment variable | Default |
|---|---|---|
server.enabled | WEBHOOKS_SERVER_ENABLED | true |
platform.enabled | WEBHOOKS_PLATFORM_ENABLED | true |
client.enabled | WEBHOOKS_CLIENT_ENABLED | false |
dashboard.enabled | WEBHOOKS_DASHBOARD_ENABLED | false |
pulse.enabled | WEBHOOKS_PULSE_ENABLED | false |
search.enabled | WEBHOOKS_SEARCH_ENABLED | false |
otel.enabled | WEBHOOKS_OTEL_ENABLED | false |
Remember the two dependencies between them: Platform implies Server, and Dashboard requires Platform. Both are spelled out on the overview.
Every environment variable the shipped config reads
The config file reads 51 WEBHOOKS_* variables. The layer gates are in the table above; these
are the rest, and the list is complete rather than selective — a variable that exists and is
documented nowhere is one a host finds by reading vendor/.
| Variable | Default | What it decides |
|---|---|---|
WEBHOOKS_HTTPS_ONLY | true | Whether an endpoint URL must be https. On, and turning it off lets a tenant register a plaintext destination for signed production traffic. An empty value counts as unset and keeps it on |
WEBHOOKS_SERVER_QUEUE | default | The queue outbound deliveries are pushed onto. Its own queue keeps a delivery burst from starving everything else the application queues |
WEBHOOKS_SERVER_CONNECTION | app default | The queue connection for those deliveries |
WEBHOOKS_SERVER_PERSISTENCE_ENABLED | false | The Server layer's own delivery log, separate from the Platform one — see Sending |
WEBHOOKS_SERVER_PERSISTENCE_PRUNE_AFTER_DAYS | 30 | Days a row of that log is kept before the scheduled model:prune removes it |
WEBHOOKS_SERVER_OUTBOX_ENABLED | false | The transactional outbox, its two tables and its sweep — see Sending |
WEBHOOKS_SERVER_OUTBOX_MAX_ATTEMPTS | no maximum | How many failed attempts end an outbox entry. Unset, the outbox keeps trying until the receiver takes the entry or refuses it for good |
WEBHOOKS_SERVER_OUTBOX_ALARM_AFTER_HOURS | 72 | Hours after which an entry still not delivered raises WebhookOutboxEntryOverdue, once. 0 switches the alarm off |
WEBHOOKS_SERVER_OUTBOX_PRUNE_AFTER_DAYS | 14 | Days a delivered or skipped outbox entry is kept before the scheduled model:prune removes it |
WEBHOOKS_CLIENT_DELETE_AFTER_DAYS | 30 | Days a received call is kept before the scheduled model:prune removes it |
WEBHOOKS_CANONICALIZE_JSON | false | Sign a canonicalized body, so a receiver that re-serializes before verifying still matches |
WEBHOOKS_ED25519_SECRET_KEY | — | The asymmetric signing key. Setting server.signing.ed25519.enabled without it is what makes every delivery fail to sign |
WEBHOOKS_SELF_SERVICE_ENABLED | false | The tenant portal. Needs its provider registered by hand as well — see the portal |
WEBHOOKS_HEALTH_ENABLED | false | Endpoint health scoring and the health board |
WEBHOOKS_PAYLOAD_VERSIONING_ENABLED | false | Whether a stored transform actually reshapes deliveries. Off, an edited transform is stored and does nothing |
WEBHOOKS_DASHBOARD_TIMEZONE | app timezone | The zone the dashboard renders in — see the dashboard |
WEBHOOKS_SEARCH_INDEX_PAYLOAD | false | Whether the delivery body is copied into the search index. Off, and off is the only safe default — see Searching the logs |
WEBHOOKS_EGRESS_ENABLED | false | Whether outbound deliveries go through a fixed-IP egress proxy, so a receiver can allowlist you — see Subscriptions and fan-out |
WEBHOOKS_EGRESS_PROXY | — | The proxy that egress goes through when it is on |
WEBHOOKS_OWNER_KEY_TYPE | auto-detected | The column type the owner key uses, when the package must not infer it from your users table |
WEBHOOKS_UI_THEME | auto | The operator console's color mode. Pinning it to light or dark drops the one inline script the package emits — see Styling the UI |
WEBHOOKS_UI_VARIANT | auto | Which rendering of the operator console you get: auto takes the WireKit one when WireKit is registered, wirekit and plain state it outright. A view you published wins over it — see the operator console |
WEBHOOKS_SERVER_CONNECT_TIMEOUT | 3 | Seconds a delivery waits to connect to an endpoint |
WEBHOOKS_SERVER_TIMEOUT | 5 | Seconds a delivery may take in total, connection included |
WEBHOOKS_SERVER_TRIES | 3 | How often one delivery is tried before it counts as failed |
WEBHOOKS_SERVER_RETRY_AFTER_MAX_DEFERRALS | 6 | How often an endpoint's Retry-After may push one delivery's retry back. 0 switches the hint off, and the delay comes from the jittered schedule |
WEBHOOKS_PLATFORM_RATE_LIMIT_ENABLED | true | The per-subscription delivery rate limit. An empty value counts as unset and keeps it on |
WEBHOOKS_PLATFORM_RATE_LIMIT_MAX_PER_MINUTE | 60 | Deliveries per subscription and minute while that limit is on |
WEBHOOKS_PLATFORM_TEST_PING_MAX_PER_MINUTE | 5 | Test pings per endpoint and minute |
WEBHOOKS_PLATFORM_RETENTION_MONTHS | 3 | Months of delivery log the platform keeps. webhooks:partition-maintenance drops the monthly partitions older than that, and deletes the rows on MySQL |
WEBHOOKS_PLATFORM_PARTITION_MONTHS_AHEAD | 3 | How many monthly partitions of the delivery log webhooks:partition-maintenance provisions ahead |
WEBHOOKS_PLATFORM_SECRET_ROTATION_WINDOW_HOURS | 24 | Hours an endpoint's previous signing secret keeps verifying after a rotation. 0 revokes it the instant it is rotated away |
WEBHOOKS_PLATFORM_DELIVERIES_WINDOW_DAYS | 30 | How many days back the portal's delivery list reads: a ceiling the reader can narrow and never widen. 0 switches the bound off |
WEBHOOKS_DASHBOARD_API_MAX_PER_MINUTE | 60 | Requests per caller and minute to the JSON metrics endpoint, while it is exposed. 0 switches the brake off |
WEBHOOKS_DASHBOARD_DELIVERIES_WINDOW_DAYS | 30 | How many days back the dashboard's delivery table reads, the same kind of ceiling. 0 switches the bound off |
WEBHOOKS_SELF_SERVICE_SECRET_REVEAL_TTL | 60 | Seconds a revealed signing secret stays on screen in the portal |
WEBHOOKS_SELF_SERVICE_MAX_ENDPOINTS_PER_TENANT | no cap | How many endpoints one tenant may register through the portal. 0 allows none |
WEBHOOKS_HEALTH_REFRESH_MIN_INTERVAL_SECONDS | 60 | How long a cached health score stands before a finished delivery recomputes it. 0 recomputes on every one |
A numeric limit read from the environment takes a whole number. A value below its floor, a
fraction, a typo or an empty value keeps the shipped default, so a mistake in .env never
loosens a limit. The floor is 1, and 0 for the endpoint cap, where 0 allows no endpoint at all,
and for the rotation window, where 0 revokes the previous secret at once. Six read an explicit
0 as off, and only that 0 does it: WEBHOOKS_SERVER_RETRY_AFTER_MAX_DEFERRALS,
WEBHOOKS_SERVER_OUTBOX_ALARM_AFTER_HOURS, WEBHOOKS_HEALTH_REFRESH_MIN_INTERVAL_SECONDS,
WEBHOOKS_DASHBOARD_API_MAX_PER_MINUTE and the two
delivery windows, WEBHOOKS_PLATFORM_DELIVERIES_WINDOW_DAYS and
WEBHOOKS_DASHBOARD_DELIVERIES_WINDOW_DAYS.
Sub-feature switches that default off
server.persistence.enabledserver.ordering.enabled— ordered delivery forPendingWebhook::orderedBy(); the Server layer migrates itswebhook_delivery_streamstable while it is on, and an ordered delivery dispatched while it is off is refused. See Sendingserver.outbox.enabled— the transactional outbox forOutbox::record(); the Server layer migrates itswebhook_outboxandwebhook_outbox_streamstables and scheduleswebhooks:outboxwhile it is on, and a record while it is off is refused. See Sendingserver.signing.canonicalizeserver.signing.ed25519.enabledcore.egress.enabled— the master gate of the egress section; while it is off a configuredcore.egress.proxyis ignored and deliveries go out directplatform.self_service.enabledplatform.health.enabledplatform.payload_versioning.enabledplatform.validate_payloadsplatform.wildcardsdashboard.operator(WEBHOOKS_DASHBOARD_OPERATOR)dashboard.all_tenants(WEBHOOKS_DASHBOARD_ALL_TENANTS) — the cross-tenant support console; wider thanoperator, and it requires its own ability (dashboard.all_tenants_ability,WEBHOOKS_DASHBOARD_ALL_TENANTS_ABILITY, defaultview-all-tenant-webhooks). Undefined ability = the dashboard refuses to load, and so does a blank name, which is repaired in the key rather than with a gate. See the dashboarddashboard.expose_json_api
Gated by their own ability
dashboard.payload.ability(WEBHOOKS_DASHBOARD_PAYLOAD_ABILITY, defaultview-webhook-payload) — guards the delivery body in the detail drawer, separately from the dashboard itself. Fails closed: undefined ability = no values.dashboard.payload.denied(WEBHOOKS_DASHBOARD_PAYLOAD_DENIED, defaultredacted) picks what a denied read shows —redactedkeeps the structure and replaces the values with their types, along with every key that reads as data rather than as a field name,hiddenshows only the notice. See the dashboard
Sub-features that default ON
Each of these bounds something that is otherwise unbounded, so each starts enabled — the first two protect the delivery queue from one misbehaving endpoint, the last three bound what a person can repeat. Every one has a switch, and none of them is mentioned anywhere else as something you can turn off.
| Key | Default | Turning it off means |
|---|---|---|
platform.circuit_breaker.enabled | true (threshold: 10) | An endpoint failing repeatedly is never auto-disabled; it keeps receiving every event and keeps failing. See Reliability |
platform.rate_limit.enabled | true (max_per_minute: 60) | A single subscription can enqueue without limit, so one burst can starve the queue for everyone else |
platform.test_ping.max_per_minute | 5 | A manual test ping bypasses the rate limit above, on purpose. Without a brake of its own it is unbounded, aimed at a destination the requester chose — the SSRF guard decides where a ping lands, never how often. null removes it |
platform.self_service.registrations_per_minute | 10 | How fast one tenant may register through the portal. max_endpoints_per_tenant bounds how many it ends up with, not how fast — and nothing at all when unset. null removes it; bulk imports belong on the unbraked Webhooks::subscribe() |
dashboard.api_max_per_minute | 60 | How often one caller may ask the JSON metrics endpoint, which computes live percentiles over up to 30 days of rows on every request. The brake sits on that route alone, so the page's own requests are not throttled. 0 removes it, for a host that throttles the path itself |
Per-call overrides
Every server key is the default for each outbound call; the PendingWebhook builder
overrides any of them per delivery (see Sending).
Turning on server.signing.ed25519.enabled switches every delivery to the asymmetric v1a
signature made with server.signing.ed25519.secret_key — the per-endpoint shared secret then
plays no part, and each receiver verifies with the public key alone.
Keys worth reading twice
| Key | Why |
|---|---|
core.ssrf.allowed_hosts | An opt-out, not an allowlist — it skips resolution, classification and the IP pin. See Security |
core.egress.proxy | Two things at once. It does nothing until core.egress.enabled is true — the gate is checked first and the proxy is ignored silently while it is off. And once it IS on, a proxy resolves the hostname itself, so the SSRF guard's IP pin is not enforced through it |
platform.owner_key_type | bigint / uuid / ulid, and it must be set before migrating |
platform.retention_months | How far back the delivery log is kept; drives partition drops (PostgreSQL) or chunked deletes (MySQL) |
platform.deliveries.window_days | How many days back the tenant's delivery list reads — a ceiling, not merely a default: the panel may narrow beneath it and can never reach past it, because a public Livewire property is writable from the browser. It exists because webhook_deliveries is range-partitioned by month, so a read with no lower bound on created_at cannot be pruned and visits every partition there is. Nothing goes red; the cost arrives with the DATA. 0 switches the bound off. dashboard.deliveries.window_days is the same key for the operator table |
platform.deliveries.show_errors | Whether the tenant's delivery list renders the stored error text. Off by default, and that is a privacy decision rather than a cosmetic one: the text comes from the endpoint's own response and the transport, so what it may reveal about a host's installation is the host's knowledge, not this package's. The panel can only ever decline it, never grant it |
server.backoff.retry_after_cap | The longest wait the queue can hold a job for — a different quantity from the jitter cap. 0 ignores the hint and uses the jittered schedule instead, rather than retrying with no wait |
otel.enabled | The one gate in this table that does nothing on its own. ServerServiceProvider binds Pushery\Webhooks\Server\Telemetry\SpanEmitter to a NullSpanEmitter whose emit() is empty, so flipping the flag produces no span — permanently, with no error and no log line. Bind your own from a service provider's register(), receiving a DeliverySpanAttributes per finished delivery:$this->app->bind(SpanEmitter::class, MyOtelSpanEmitter::class); |
dashboard.percentiles.driver | live (default, no extension, identical numbers) or tdigest (PostgreSQL extension only) |
platform.self_service.register_routes | true mounts the portal's own pages; false registers the panels and none of the portal's pages, for a host embedding them in a screen it already guards; the secret panel's script route /_webhooks/webhooks-ui.js stays either way. The panels then drop the links they cannot resolve. See the portal |
platform.self_service.refuse_with | What a refusal says when a reader lacks manage-webhook-endpoints: 403 (default) denies, 404 hides. The gate is unchanged either way, and endpoint ownership already fails not-found before its policy. See the portal |
admin.ability | A check per ACTION on the operator console, which a page gate cannot give: that decides who gets a Livewire snapshot, and every click after is a separate request. null is today's behavior, and this is not tenant scoping — see the console |
dashboard.timezone | The zone the dashboard renders timestamps in, which is a different question from the zone they are stored or read in. null (default) leaves every value in app.timezone, exactly as before. Set a zone identifier, or a class implementing DashboardTimezoneResolver when the answer is per reader — see the dashboard |
ui.secondary_surface | The WireKit surface every shipped screen draws its secondary actions with -- the two operator screens, the self-service panels and the dashboard alike. outline by default, so each such action has a border and reads as a button beside the primary one. ghost draws them without border or fill, the default before 3.20.0; beside a label with no icon such a button reads as plain text. Any surface WireKit knows works. One button is not read from here and is not a secondary action: the active/inactive toggle in the endpoint list, whose ghost is the OFF half of a two-state control |
ui.page_heading_level | The heading level of each full-page screen's title — the dashboard, the portal, the health board and the transform editor, which all draw it with WireKit's page-header, so it carries the size and spacing the other screens of a WireKit application use. 1 by default, because on a page of its own the title is the document's heading. Set 2 when your layout carries its own h1, visible or not, and the screen's title becomes the page's first section. Any other value is ignored in favor of 1 |
ui.row_action_icons | The icon each row action of the self-service endpoint list carries, as a semantic WireKit alias -- ping, secret, edit, transform, delete -- plus delete_confirm for the button that confirms the deletion inside the dialog. delete_confirm is not set by default and then follows delete, so both buttons of one deletion carry the same symbol; set it for a symbol of its own. Set one entry to null to drop that icon, or the whole key to [] for the label-only row this package shipped before 3.4.0. The surface above cannot fix a row on its own: ghost reads as a text link mainly for want of anything else to read, and a tinted surface with no icon trades one flat row for another. Every shipped alias is one all four WireKit presets carry (heroicons, lucide, phosphor, tabler), so the default resolves whichever the host configured; a host naming its own alias owns that choice. Both icon packages are suggest rather than require, so without them WireKit draws its inert placeholder and the row reads exactly as it did before |
ui.deliveries.default_window_days | How far back the operator delivery log opens, in days — a default and not a ceiling, which is the difference between this screen and the two tenant-facing lists. webhook_deliveries is range-partitioned by month, so a query with no lower bound on created_at cannot be pruned and reads every partition there is; both date fields started empty, so a fresh instance did exactly that on its first render. The date is preset in the From field, visible, and the reader can clear it — on the operator's own console that is a decision they take rather than a state they arrive in. 0 opens it unbounded. platform.deliveries.window_days and dashboard.deliveries.window_days are the ceilings on the two lists whose properties a tenant can write |
client.expected | The client names this application requires. Empty by default, so it changes nothing until you fill it in. Every other fault webhooks:preflight reports is found by reading the entries that exist, which leaves one it can never see: an entry that should exist and does not. The route does not disappear with it — a route hangs on the macro, not on the entry — so a client lost to a bad merge or an unset variable leaves an endpoint that refuses every delivery, with nothing saying why. Only your application knows what it expects, so name them here: 'expected' => ['github', 'stripe'] |
models | Replaces a package model with your subclass on every path: every query, every row the package writes, and the relations between its models — 'models' => [WebhookDelivery::class => \App\Models\Delivery::class]. Use it when you need your own relations, scopes or casts on the row; your subclass inherits the package's table, so keep it there. It is the default, and the two settings that name a model for one purpose still win for that purpose: a client entry's own model for the calls it stores, and dashboard.source_model for what the dashboard reads. Three readers look past all three on purpose and read the tables themselves — webhooks:prune-orphaned-payloads, webhooks:import-calls --dry-run and Webhooks::forgetEvents() — because a scope on your subclass would hide rows from a check that must see every one: the sweep would delete payloads those rows still reference, and a deletion would leave the rows it could not see. A class that does not exist or does not extend the package class is ignored. In your own code, WebhookDelivery::model() returns the configured class and WebhookDelivery::resolve() a new instance of it |
admin.abilities | The same check, but naming an ability per action ('*' is the catch-all) and authorizing it with no argument. This is the key to use when your capabilities come from a permission package that resolves through its own Gate::before hook — a permission name in admin.ability denies every operator, silently. See the console |