Skip to main content

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_tenant leaves registrations_per_minute and 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_hosts and core.egress.published_ips are 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' => null is how you remove a brake.
note
Running 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

SectionGate (default)Contents
corealways onsigning.scheme, the ssrf policy, the egress IPs this installation publishes + proxy
serverserver (on) — forced on by platformqueue/connection, signing (canonicalize, ed25519), http_verb, timeouts, tries, backoff, no_retry_on_4xx, persistence, large_payload, verify_ssl, horizon_tags
platformplatform (on)catalog, validate_payloads, circuit_breaker, rate_limit, test_ping, retention/partitioning, self_service, deliveries (window_days, show_errors), health, payload_versioning
clientclient.enabled (off)raw_body_capture, per-source configs, expected, delete_after_days
dashboarddashboard.enabled (off) — needs platformroute prefix/middleware, source_model, windows, poll_interval, percentiles, metrics.refresh, expose_json_api + api_path, deliveries.window_days, payload (the body's own ability)
pulsepulse.enabled (off)the internal-ops Pulse card
searchsearch.enabled (off)optional Laravel Scout full-text index over the delivery/call logs
otelotel.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
uialways ontheme (auto / light / dark) for the package's own full-page layouts, variant, assets, csp_nonce, secondary_surface, and deliveries.default_window_days for the operator log
adminalways onability and abilities — the optional per-action check on the operator console, both unset by default

Two more sit above the layers:

KeyDefaultWhat it does
database.connectionthe app default (WEBHOOKS_DB_CONNECTION)Pins every model, migration and analytics query to one connection — the side-car topology
schedule.enabledtruefalse registers nothing in the scheduler — see Scheduled maintenance

The layer gates and their environment variables

KeyEnvironment variableDefault
server.enabledWEBHOOKS_SERVER_ENABLEDtrue
platform.enabledWEBHOOKS_PLATFORM_ENABLEDtrue
client.enabledWEBHOOKS_CLIENT_ENABLEDfalse
dashboard.enabledWEBHOOKS_DASHBOARD_ENABLEDfalse
pulse.enabledWEBHOOKS_PULSE_ENABLEDfalse
search.enabledWEBHOOKS_SEARCH_ENABLEDfalse
otel.enabledWEBHOOKS_OTEL_ENABLEDfalse

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 29 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/.

VariableDefaultWhat it decides
WEBHOOKS_HTTPS_ONLYtrueWhether an endpoint URL must be https. On, and turning it off lets a tenant register a plaintext destination for signed production traffic
WEBHOOKS_SERVER_QUEUEdefaultThe queue outbound deliveries are pushed onto. Its own queue keeps a delivery burst from starving everything else the application queues
WEBHOOKS_SERVER_CONNECTIONapp defaultThe queue connection for those deliveries
WEBHOOKS_SERVER_PERSISTENCE_ENABLEDfalseThe Server layer's own delivery log, separate from the Platform one — see Sending
WEBHOOKS_CANONICALIZE_JSONfalseSign a canonicalized body, so a receiver that re-serializes before verifying still matches
WEBHOOKS_ED25519_SECRET_KEYThe asymmetric signing key. Setting server.signing.ed25519.enabled without it is what makes every delivery fail to sign
WEBHOOKS_SELF_SERVICE_ENABLEDfalseThe tenant portal. Needs its provider registered by hand as well — see the portal
WEBHOOKS_HEALTH_ENABLEDfalseEndpoint health scoring and the health board
WEBHOOKS_PAYLOAD_VERSIONING_ENABLEDfalseWhether a stored transform actually reshapes deliveries. Off, an edited transform is stored and does nothing
WEBHOOKS_DASHBOARD_TIMEZONEapp timezoneThe zone the dashboard renders in — see the dashboard
WEBHOOKS_SEARCH_INDEX_PAYLOADfalseWhether the delivery body is copied into the search index. Off, and off is the only safe default — see Searching the logs
WEBHOOKS_EGRESS_ENABLEDfalseWhether outbound deliveries go through a fixed-IP egress proxy, so a receiver can allowlist you — see Subscriptions and fan-out
WEBHOOKS_EGRESS_PROXYThe proxy that egress goes through when it is on
WEBHOOKS_OWNER_KEY_TYPEauto-detectedThe column type the owner key uses, when the package must not infer it from your users table
WEBHOOKS_UI_THEMEautoThe 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_VARIANTautoWhich 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

Sub-feature switches that default off

  • server.persistence.enabled
  • server.signing.canonicalize
  • server.signing.ed25519.enabled
  • core.egress.enabled — the master gate of the egress section; while it is off a configured core.egress.proxy is ignored and deliveries go out direct
  • platform.self_service.enabled
  • platform.health.enabled
  • platform.payload_versioning.enabled
  • platform.validate_payloads
  • platform.wildcards
  • dashboard.operator (WEBHOOKS_DASHBOARD_OPERATOR)
  • dashboard.all_tenants (WEBHOOKS_DASHBOARD_ALL_TENANTS) — the cross-tenant support console; wider than operator, and it requires its own ability (dashboard.all_tenants_ability, WEBHOOKS_DASHBOARD_ALL_TENANTS_ABILITY, default view-all-tenant-webhooks). Undefined ability = the dashboard refuses to load. See the dashboard
  • dashboard.expose_json_api

Gated by their own ability

  • dashboard.payload.ability (WEBHOOKS_DASHBOARD_PAYLOAD_ABILITY, default view-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, default redacted) picks what a denied read shows — redacted keeps the structure and replaces the values with their types, hidden shows 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 two 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.

KeyDefaultTurning it off means
platform.circuit_breaker.enabledtrue (threshold: 10)An endpoint failing repeatedly is never auto-disabled; it keeps receiving every event and keeps failing. See Reliability
platform.rate_limit.enabledtrue (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_minute5A 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_minute10How 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()

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

KeyWhy
core.ssrf.allowed_hostsAn opt-out, not an allowlist — it skips resolution, classification and the IP pin. See Security
core.egress.proxyTwo 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_typebigint / uuid / ulid, and it must be set before migrating
platform.retention_monthsHow far back the delivery log is kept; drives partition drops (PostgreSQL) or chunked deletes (MySQL)
platform.deliveries.window_daysHow 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_errorsWhether 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_capThe 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.enabledThe 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.driverlive (default, no extension, identical numbers) or tdigest (PostgreSQL extension only)
platform.self_service.register_routestrue mounts the portal's own pages; false registers the panels and mounts nothing, for a host embedding them in a screen it already guards. The panels then drop the links they cannot resolve. See the portal
platform.self_service.refuse_withWhat 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.abilityA 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.timezoneThe 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_surfaceThe WireKit surface the two operator screens draw their SECONDARY actions with, ghost by default — which is what they have always used, so leaving it alone changes nothing. A design system usually settles on one secondary style, and a borderless untinted button beside a tinted one reads as a third rank where there are two. Until this existed the only way to change it was to publish the view and carry the diff through every update. Scoped to the operator screens: the self-service views also use ghost, and several of those are icon-only controls in a table row, where a third rank is right
ui.deliveries.default_window_daysHow 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.expectedThe 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']
admin.abilitiesThe 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