{"id":51690840,"url":"https://github.com/pushery/webhooks-for-laravel","last_synced_at":"2026-07-16T02:00:58.131Z","repository":{"id":368874806,"uuid":"1287020096","full_name":"pushery/webhooks-for-laravel","owner":"pushery","description":"An all-in-one, config-gated Laravel toolkit for signed inbound and outbound webhooks, customer-managed endpoints, and delivery observability","archived":false,"fork":false,"pushed_at":"2026-07-14T11:31:18.000Z","size":1741,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-14T13:23:50.206Z","etag":null,"topics":["event-catalog","event-driven","laravel","laravel-framework","laravel-package","logs","multi-tenancy","multi-tenant","outgoing-webhooks","php","php-package","php8","saas","signature-verification","ssrf-protection","standard-webhooks","subscriptions","webhook","webhook-delivery","webhooks"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pushery.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-02T09:56:25.000Z","updated_at":"2026-07-14T11:31:23.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pushery/webhooks-for-laravel","commit_stats":null,"previous_names":["pushery/webhooks-for-laravel"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/pushery/webhooks-for-laravel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pushery%2Fwebhooks-for-laravel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pushery%2Fwebhooks-for-laravel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pushery%2Fwebhooks-for-laravel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pushery%2Fwebhooks-for-laravel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pushery","download_url":"https://codeload.github.com/pushery/webhooks-for-laravel/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pushery%2Fwebhooks-for-laravel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35527286,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-16T02:00:06.687Z","response_time":83,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["event-catalog","event-driven","laravel","laravel-framework","laravel-package","logs","multi-tenancy","multi-tenant","outgoing-webhooks","php","php-package","php8","saas","signature-verification","ssrf-protection","standard-webhooks","subscriptions","webhook","webhook-delivery","webhooks"],"created_at":"2026-07-16T02:00:57.462Z","updated_at":"2026-07-16T02:00:58.115Z","avatar_url":"https://github.com/pushery.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/pushery/webhooks-for-laravel\"\u003e\n    \u003cimg src=\"art/header.png\" alt=\"Webhooks for Laravel\" width=\"100%\"\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n# Webhooks for Laravel\n\n[![Latest Version](https://img.shields.io/packagist/v/pushery/webhooks-for-laravel.svg)](https://packagist.org/packages/pushery/webhooks-for-laravel)\n[![PHP Version](https://img.shields.io/packagist/dependency-v/pushery/webhooks-for-laravel/php.svg)](https://packagist.org/packages/pushery/webhooks-for-laravel)\n[![PHPStan](https://img.shields.io/badge/PHPStan-max-blue.svg)](https://phpstan.org)\n[![Code Style](https://img.shields.io/badge/code%20style-pint-orange.svg)](https://laravel.com/docs/pint)\n[![License](https://img.shields.io/packagist/l/pushery/webhooks-for-laravel.svg)](LICENSE)\n\nAn all-in-one, config-gated webhooks toolkit for Laravel. It **sends** signed\noutbound webhooks, **receives** and verifies inbound ones, gives your customers a\n**self-service** portal to manage their own endpoints, and puts an **observability**\ndashboard over the whole delivery log — and you switch on only the layers you need.\nSignatures are [Standard Webhooks](https://www.standardwebhooks.com) by default, so\nevery delivery is verifiable out of the box by any Standard Webhooks consumer in any\nlanguage. The engine is entirely in-house — no third-party webhook-engine\ndependency — and its storage runs on **PostgreSQL or MySQL 8.4+** (or on no database\nat all, if you only send).\n\n## The layered architecture\n\nThe package is five layers stacked on a shared crypto/transport core. Each has a\nsingle switch, so you pay only for what you turn on. **Configure only what you need.**\n\n| Layer         | What it does                                                                 | Gate                                        |\n| ------------- | ---------------------------------------------------------------------------- | ------------------------------------------- |\n| **Core**      | Signing dialects, the SSRF guard, and the HTTP transport shared by everything | Always on                                   |\n| **Server**    | The outbound delivery engine — sign, queue, retry, back off                  | On by default (`server`)                    |\n| **Platform**  | Endpoint subscriptions + event fan-out, the self-service portal, health scoring, payload transforms, egress allowlist, AsyncAPI export | On by default (`platform`); each sub-feature opt-in |\n| **Client**    | Inbound receiving — verify, de-duplicate, store and queue incoming webhooks  | Opt-in (`client.enabled`, default `false`)  |\n| **Dashboard** | A customer-facing observability UI over the delivery log                      | Opt-in (`dashboard.enabled`) **and** not auto-registered |\n\nSending and the platform management layer work as soon as the package is installed.\nReceiving, the self-service portal, endpoint health scoring, payload transforms, the\ndashboard, Laravel Pulse, Scout search, OpenTelemetry, canonical-JSON signing,\nEd25519 signing, the egress proxy, and standalone delivery persistence are each\nindividually opt-in and off until you enable them.\n\n**Two dependencies between the gates — the switches are not fully independent:**\n\n- **Platform implies Server.** Fan-out delivers *through* the Server engine, so\n  `platform.enabled=true` boots the Server layer regardless of `server.enabled`.\n  Setting `WEBHOOKS_SERVER_ENABLED=false` on its own therefore changes nothing — to\n  stop outbound delivery entirely, set **both** to `false`.\n- **Dashboard requires Platform.** The dashboard reads Platform's `webhook_deliveries`\n  log, whose migration only runs while `platform.enabled=true`. A dashboard without the\n  Platform layer has no table to read (unless you point `dashboard.source_model` at a\n  delivery-log model you own).\n\n## Requirements\n\n- PHP 8.4+ with `ext-curl`, `ext-json`, `ext-sodium`\n- Laravel 13+\n- **A database — for the layers that persist.** The Platform, Client, Dashboard and\n  standalone-persistence layers store their tables in **PostgreSQL 13+ or MySQL 8.4+**. A\n  **send-only** app (`platform.enabled=false`, no `server.persistence`)\n  runs **no migrations at all** and needs no database — see\n  [Send-only setup](#send-only-setup-no-database).\n- A queue worker for outbound delivery (Redis recommended so retry backoff never blocks\n  other work)\n- The UI layers (dashboard, self-service portal, operator console) additionally\n  need `livewire/livewire` and `pushery/wirekit` — see [Styling the UI](#styling-the-ui)\n\n### Which database?\n\nReach for a topology in this order — lead with what your app already has, **not** with an\nengine demand:\n\n| Topology              | When                                              | What you run                                                                                                  |\n| --------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |\n| **T1 — Send-only**    | You only *send* webhooks                          | **No database.** Platform off; the Server engine needs only a queue.                                          |\n| **T2 — Same database**| You persist, on the engine your app already uses  | PostgreSQL **or** MySQL 8.4+ — the package migrates its tables into your app's own connection.                |\n| **T3 — Side-car**     | You persist, but want the webhook tables elsewhere| Point [`webhooks.database.connection`](#a-dedicated-database-connection) at a dedicated connection — e.g. a MySQL app with a PostgreSQL side-car. |\n\n**The one-line recommendation: don't switch engines for this package — use the one your\napp already runs on.** PostgreSQL is the reference engine and keeps a few storage\naccelerations MySQL can't express, but **every guarantee the package makes holds\nidentically on both** — exact percentile numbers, race-free dedupe, the `body_sha256`\nbyte-fidelity promise, the DB-enforced GDPR-erasure cascade, DST-safe timestamps, and\ncase-sensitive identity. MySQL users give up storage *optimizations*, never *correctness*.\n\n### Choosing your database\n\nEvery row below is a PostgreSQL-only storage optimization. **None of them changes a\nresult** — they change cost at scale. Read the recommendation as *\"this is when, and only\nwhen, the difference is worth an engine.\"*\n\n| Difference (PostgreSQL only)                                                     | When it hits you                                                             | Tip                                                                                            | Recommendation                                                             |\n| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| **O(1) retention** — PG drops an old month as a partition; MySQL runs an indexed, chunked `DELETE`. | Invisible below ~1M deliveries/month; real IO pressure at tens of millions. | Lower `platform.retention_months`, enable payload offload, run `webhooks:partition-maintenance` off-peak. | Above ~1M deliveries/month **and** long retention → PostgreSQL. Otherwise MySQL is fine. |\n| **Index bounded by the open backlog** — PG partial indexes; MySQL indexes all history. | Same volume threshold.                                                      | Nothing to configure — the composite indexes are used natively on both.                       | Not worth an engine on its own; folds into the retention call above.       |\n| **Indexed containment search *into* an inbound payload** — PG `jsonb` GIN.       | Only if you search *inside* stored payloads.                                | **No shipped query uses it** — nothing breaks. Search deliveries/calls with Scout + Meilisearch (already wired). | Not an engine-choice factor.                                               |\n| **The `tdigest` percentile tier** — an optional PG extension.                    | Only at very high dashboard volume, and only if you set `dashboard.percentiles.driver = 'tdigest'`. | The default `live` driver is the same speed **and returns identical numbers** on both engines. | Not a reason to pick PostgreSQL — and `tdigest` isn't on Neon (Laravel Cloud's Postgres) either, so this tier is unavailable there regardless. |\n| **`max_allowed_packet`** — MySQL defaults to 64 MB vs PG's ~1 GB.                | Only with multi-MB *single* events.                                        | Enable offload (`server.large_payload.enabled = true`, and the inbound offload) so a big body never reaches the column. | Enable offload on MySQL if you emit multi-MB events.                       |\n\n**One hard warning — read this if you run MySQL.** The package declares\n`utf8mb4_0900_as_cs` (case- **and** accent-sensitive) on every identity column, on\npurpose. **Do not `ALTER` it back to a `_ci` collation.** Under a case-insensitive\ncollation `evt_AbC` and `evt_abc` collapse into one dedupe row, and a distinct,\nsignature-verified webhook is answered `200` and **silently discarded**. `webhooks:preflight`\nand a schema check guard the shipped schema, but a manual `ALTER` can still undo it.\n\n**MariaDB is not supported, at any tier** — it is rejected loudly at migrate time and by\n`webhooks:preflight`. Its `JSON` is a `LONGTEXT` alias, and it has neither the multi-valued\nindex the fan-out lookup needs nor functional indexes. Use MySQL 8.4+ or PostgreSQL.\n\n\u003e **Deploying to [Laravel Cloud](https://cloud.laravel.com)?** Cloud offers **both** a\n\u003e first-party **MySQL 8.4** database and **Neon-powered Serverless Postgres** — either\n\u003e works. Two cautions: **(1)** run migrations against your database's **direct**,\n\u003e non-pooled endpoint — `migrate` and `webhooks:partition-maintenance` issue transactional\n\u003e DDL, which a transaction pooler (PgBouncer, Neon's pooled endpoint) breaks; **(2)** Neon\n\u003e does not ship the `tdigest` extension, so `dashboard.percentiles.driver = 'tdigest'` is\n\u003e unavailable there — the default `live` driver needs no extension and returns the same\n\u003e numbers.\n\n### A dedicated database connection\n\nBy default the package migrates its tables into your app's **default** connection. To keep\nthem on a different one — the headline case being a MySQL app with a **PostgreSQL side-car**\nfor the webhook tables (topology T3) — set `webhooks.database.connection` to a connection\ndefined in `config/database.php`:\n\n```env\nWEBHOOKS_DB_CONNECTION=webhooks_pgsql\n```\n\nEvery model, migration and analytics query then resolves that one connection, so the\npackage never silently splits across two databases; leave it unset and everything stays on\nthe app default. `php artisan webhooks:preflight` prints the connection it resolved. When\nyou test a host app on this topology, transact **both** connections so each case rolls back:\n\n```php\nprotected $connectionsToTransact = ['mysql', 'webhooks_pgsql'];\n```\n\n### Scheduled maintenance, and turning it off per tenant\n\nThe package schedules its own maintenance against the default connection — partition rolling,\nrotated-secret revocation, the dashboard rollup refresh, endpoint-health sweeps and log\npruning. A single-database app wants this on (the default), and it just works.\n\nA **DB-per-tenant** host must turn it off, or the maintenance runs only on the central\ndatabase and never on a tenant's — the delivery log grows unbounded and the dashboard reads\nempty. Set `webhooks.schedule.enabled` to `false` and the package registers **nothing** in the\nscheduler; the commands are unchanged, so run them yourself inside your tenant loop:\n\n```php\n// config/webhooks.php\n'schedule' =\u003e ['enabled' =\u003e false],\n\n// then, in your own scheduler, per tenant:\nforeach (Tenant::active() as $tenant) {\n    $tenant-\u003erun(fn () =\u003e Artisan::call('webhooks:partition-maintenance'));\n    // …and webhooks:revoke-rotated-secrets, webhooks:refresh-metrics, model:prune, and so on.\n}\n```\n\n## Installation\n\n```bash\ncomposer require pushery/webhooks-for-laravel\n```\n\nPublish the config and migrations, then migrate:\n\n```bash\nphp artisan vendor:publish --tag=webhooks-config\nphp artisan vendor:publish --tag=webhooks-migrations\nphp artisan migrate\n```\n\n`webhooks-migrations` publishes the **Platform** migrations (the subscriptions table and\nthe delivery log). Each other layer has its own tag — `webhooks-client-migrations`,\n`webhooks-server-migrations`, `webhooks-dashboard-migrations` — so you only ever migrate\nthe layers you switched on; see [Publishable tags](#publishable-tags). Publishing is\noptional: with `$runsMigrations` left alone (the default), every enabled layer registers\nits own migrations and `php artisan migrate` just runs them.\n\nEvery screen this package ships — the [dashboard](#observability-dashboard), the\n[self-service portal](#self-service-portal-opt-in) and the\n[operator console](#operator-console-opt-in) — is a Livewire component built from\nWireKit design-system components. If you plan to use any of them, install both first;\nwithout them a shipped view fails with `Unable to locate a class or view for component\n[wirekit::card]`:\n\n```bash\ncomposer require livewire/livewire pushery/wirekit\n```\n\nA host on a different UI kit instead publishes the views and restyles them (see\n[Publishable tags](#publishable-tags)). Sending and receiving need neither package.\n\n## Quickstart\n\n### Send a signed webhook\n\n```php\nuse Webhooks\\Server\\PendingWebhook;\n\nPendingWebhook::create()\n    -\u003eurl('https://example.com/webhooks')\n    -\u003epayload(['invoice_id' =\u003e 'in_123', 'amount' =\u003e 4200])\n    -\u003euseSecret('whsec_your_endpoint_secret')\n    -\u003edispatch();\n```\n\nThe delivery is queued, signed with a Standard Webhooks signature, and retried with\nbackoff. Run a queue worker (or use `-\u003edispatchSync()` to send inline).\n\n`dispatch()` returns the `WebhookDeliveryData` it queued, whose `messageId` (stable across\nretries) is your correlation key — a **Server-only** app, with no Platform delivery row, records\nit against its own log and any later status callback: `$id = PendingWebhook::create()…-\u003edispatch()-\u003emessageId;`.\n\n### Receive and verify one\n\nEnable the Client layer and describe the producer in `config/webhooks.php`:\n\n```php\n'client' =\u003e [\n    'enabled' =\u003e true,\n    'configs' =\u003e [\n        [\n            'name' =\u003e 'partner',\n            'secret' =\u003e env('PARTNER_WEBHOOK_SECRET'),\n            // 'scheme' defaults to Standard Webhooks; set it per source for others.\n            'process' =\u003e \\App\\Jobs\\HandlePartnerWebhook::class,\n        ],\n    ],\n],\n```\n\nPoint a route at it with the macro (registered only while the Client layer is on):\n\n```php\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::webhooks('webhooks/partner', 'partner');\n```\n\nAn authentic request is verified, de-duplicated, stored and dispatched to your job; a\nrequest whose signature is invalid, expired or malformed is answered `401` and never\nreaches your job.\n\n### Send-only setup (no database)\n\nIf all you want is the signed, SSRF-guarded, retrying **sender**, switch the Platform\nlayer off and skip the migrations entirely — no PostgreSQL, no tables, no queries:\n\n```php\n// config/webhooks.php\n'platform' =\u003e ['enabled' =\u003e false, /* … */],\n'server' =\u003e ['persistence' =\u003e ['enabled' =\u003e false], /* … */],\n```\n\n`PendingWebhook` keeps working exactly as above (it needs only a queue), and the package\nruns on whatever database your app already uses — or none.\n\n## Sending (Server layer)\n\n`Webhooks\\Server\\PendingWebhook` is an immutable, fluent builder in the shape of Laravel's\nown `PendingRequest`/`PendingMail`: every setter returns a clone, so a half-built call\nis a reusable template. `Webhooks\\Server\\Facades\\WebhookSender::to($url)` is a thin,\ndiscoverable entry point to the same builder.\n\n```php\nuse Webhooks\\Server\\PendingWebhook;\n\nPendingWebhook::create()\n    -\u003eurl('https://example.com/webhooks')\n    -\u003epayload(['invoice_id' =\u003e 'in_123'])   // encoded to JSON and signed\n    -\u003euseSecret('whsec_…')\n    -\u003eforEventType('invoice.paid')          // recorded and tagged\n    -\u003edispatch();\n```\n\nSend a raw, pre-serialized body instead of an array with\n`-\u003esendRawBody($body, 'application/json')`.\n\n**Secret rotation.** Sign with the current *and* previous secret during a rotation\nwindow so a consumer that still holds the old secret keeps verifying while it migrates.\nFor a registered endpoint (`Webhooks::rotateSecret()`) the window is bounded by\n`platform.secret_rotation_window_hours` (24 by default) and it CLOSES: once it has, the\nold secret is cleared from the row and can no longer sign or verify — which is the whole\npoint of rotating away from it. `php artisan webhooks:revoke-rotated-secrets` (scheduled\nhourly) sweeps the endpoints that went quiet before their window elapsed:\n\n```php\nPendingWebhook::create()\n    -\u003eurl($url)\n    -\u003epayload($payload)\n    -\u003euseSecrets(current: 'whsec_new', previous: 'whsec_old')\n    -\u003edispatch();\n```\n\n**A different signing scheme.** The default is the Standard Webhooks HMAC dialect; opt\ninto another (for example asymmetric Ed25519) per call:\n\n```php\nuse Webhooks\\Core\\Signing\\Ed25519Scheme;\n\nPendingWebhook::create()\n    -\u003eurl($url)\n    -\u003epayload($payload)\n    -\u003esignUsing(Ed25519Scheme::class)\n    -\u003euseSecret('whsk_…')                   // base64 Ed25519 secret key\n    -\u003edispatch();\n```\n\n**Retries, backoff and Retry-After.** The backoff is exponential with full jitter,\ncapped. A retryable `429`/`503` carrying a `Retry-After` header is honored when\nscheduling the next attempt, clamped to its own ceiling (`server.backoff.retry_after_cap`\n— the longest wait the queue can hold a job for, which is a different quantity from the\njitter cap). When an endpoint asks for longer than that, the delivery comes back at the\ncap and the wait is **not** charged against `tries`, so a long rate-limit window cannot\nexhaust the delivery before the endpoint is ready for it:\n\n```php\nuse Webhooks\\Server\\Backoff\\ExponentialWithJitter;\n\nPendingWebhook::create()\n    -\u003eurl($url)-\u003epayload($payload)-\u003euseSecret($secret)\n    -\u003emaximumTries(5)\n    -\u003euseBackoffStrategy(new ExponentialWithJitter(baseSeconds: 10, capSeconds: 900))\n    -\u003erespectRetryAfter()                    // on by default\n    -\u003edispatch();\n```\n\n**Timeouts, SSRF, mTLS, proxy.** Every outbound URL is vetted by the shared SSRF guard, and a\n*direct* connection is pinned to the validated IP (see [Security](#security)). Routing through\n`useProxy()` hands name resolution to the proxy, so the pin no longer applies — the proxy must\nenforce egress control itself:\n\n```php\nPendingWebhook::create()\n    -\u003eurl($url)-\u003epayload($payload)-\u003euseSecret($secret)\n    -\u003econnectTimeoutInSeconds(3)\n    -\u003etimeoutInSeconds(5)\n    -\u003everifySsl(true)                        // or a CA bundle path\n    -\u003euseMutualTls(cert: '/path/client.pem', key: '/path/client.key')\n    -\u003euseProxy('http://proxy.internal:8080')\n    -\u003edispatch();\n```\n\n**Metadata, tags, queue, connection.** Attach arbitrary `meta`, add Horizon tags, and\nchoose the queue/connection the delivery job runs on:\n\n```php\nPendingWebhook::create()\n    -\u003eurl($url)-\u003epayload($payload)-\u003euseSecret($secret)\n    -\u003emeta(['tenant_id' =\u003e $team-\u003eid])\n    -\u003ewithTags(['billing', \"team:{$team-\u003eid}\"])\n    -\u003eonQueue('webhooks')\n    -\u003eonConnection('redis')\n    -\u003edispatch();\n```\n\nTerminal methods: `-\u003edispatch()`, `-\u003edispatchSync()`, `-\u003edispatchIf($cond)`,\n`-\u003edispatchUnless($cond)`, and `-\u003etoDeliveryData()` (the immutable value object the job\ncarries).\n\n**Standalone delivery persistence (opt-in).** When you drive `PendingWebhook` directly\n*without* the Platform layer and still want a persisted, prunable record of every\ndelivery, enable `server.persistence.enabled`: a listener upserts each attempt into a\n`webhook_server_deliveries` table keyed by the message id, and rows older than\n`prune_after_days` are removed by the scheduled `model:prune`. Off by default — when the\nPlatform layer runs it owns the delivery log instead, so the two never double-log.\n\n## Receiving (Client layer)\n\nTurn on `client.enabled`, declare one entry per producer under `client.configs`, and\nroute to it with `Route::webhooks($url, $name = null, $verb = 'post')`. The macro binds\na named route (`webhooks.{name}`) and pins the config name onto it. You can also drive\nthe controller-less processor directly:\n\n```php\nuse Webhooks\\Client\\WebhookProcessor;\nuse Webhooks\\Client\\WebhookConfig;\n\n$response = new WebhookProcessor($request, WebhookConfig::forName('partner'))-\u003eprocess();\n```\n\nThe pipeline, in order: capture the exact raw bytes → verify the signature → throttle\nthe source → de-duplicate → filter → store → dispatch the handler job.\n\n- **Verification.** An invalid, expired or malformed signature responds with\n  `invalid_status` (**`401`** by default) — never `500`, because a request that can\n  never verify must not tell the sender to retry. Each failure fires a\n  `Webhooks\\Client\\Events\\InvalidWebhookSignature`.\n- **Replay protection.** The signed timestamp is checked against `tolerance_seconds`\n  (default `300`).\n- **Idempotency.** Two-tier dedupe on the producer's delivery id: a cache fast path in\n  front of a partial-unique insert, so an at-least-once sender (including this package's\n  own Server on retry) is never processed twice. A call with no id is always stored. By\n  default the id is read from the `webhook-id` header; providers that carry no delivery-id\n  header (Stripe's `evt_…` is in the body, Mollie and SendCloud send none) set `dedupe_id`\n  to read it from elsewhere — `'header:X-Delivery-Id'`, `'body:data.object.id'` (a dotted\n  path into the JSON body), or a `Webhooks\\Client\\Dedupe\\DedupeKeyResolver` class. Without\n  it the key stays null, a null collides with nothing, and dedupe silently does nothing for\n  exactly those providers.\n- **Raw-body capture.** A prepended middleware preserves the exact bytes the signature\n  was computed over, before any body parsing.\n- **Event routing.** `process` is either a single `ProcessWebhookJob` subclass or an\n  `['event.type' =\u003e JobClass, '*' =\u003e FallbackJob]` map. The job receives the stored call\n  and the parsed envelope (`$this-\u003emessage`).\n- **Rate limiting.** An optional per-source token bucket answers an over-limit request\n  `429` with `Retry-After`; a forged request never counts, because verification runs\n  first.\n- **Header redaction.** `store_headers` selects which headers persist; the `redact` list\n  (plus `Authorization` and `Cookie`, always) is masked before storage.\n\nBuilt-in receive adapters, selected per source via `scheme`:\n\n| `scheme`                       | Verifies                                                       |\n| ------------------------------ | ------------------------------------------------------------- |\n| `StandardWebhooksScheme` (default) | Any Standard Webhooks producer — including this package's own Server |\n| `'auto'`                       | States first-party intent explicitly; resolves to Standard Webhooks |\n| `StripeStyleScheme`            | The generic `Webhook-Signature: t=,v1=` dialect — **the 0.x format this package used to send**, so a 0.x consumer of yours keeps verifying |\n| `StripeScheme`                 | Stripe's `Stripe-Signature: t=,v1=` header                    |\n| `GitHubScheme`                 | GitHub's `X-Hub-Signature-256: sha256=` header                |\n| `PlainHmacScheme`              | A raw-body HMAC in a `Signature` header                       |\n| `Ed25519Scheme`               | The asymmetric `v1a` variant (static key or a JWKS endpoint)  |\n\n`StripeStyleScheme` and `StripeScheme` are **not** interchangeable: the first\nreads the generic `Webhook-Signature` header, the second pins Stripe's own\n`Stripe-Signature`. Pick by the header the producer actually sends.\n\nBecause the default receive scheme is Standard Webhooks, an app **verifies its own\ndeliveries** with `scheme =\u003e 'auto'` and no extra plumbing — a first-party round trip.\n\n## Signatures \u0026 interop\n\nThe default dialect is **[Standard Webhooks](https://www.standardwebhooks.com)** —\nbyte-compatible with the specification and its official SDKs, so any Standard Webhooks\nconsumer can verify our deliveries and we can verify theirs.\n\n- **Signed content:** `{webhook-id}.{webhook-timestamp}.{rawBody}`, HMAC-SHA256,\n  base64-encoded.\n- **Headers:** `webhook-id`, `webhook-timestamp`, and a `webhook-signature` carrying one\n  or more space-separated `v1,\u003cbase64\u003e` entries (a rotation emits two — accept if either\n  verifies).\n- **Key derivation:** strip an optional `whsec_` prefix, then base64-decode the remainder\n  to the raw HMAC key bytes.\n\nOther schemes ship for interop: `StripeScheme`, `GitHubScheme` and\n`PlainHmacScheme` (receive adapters for those producers), and the asymmetric\n**`Ed25519Scheme`** — the Standard Webhooks `v1a` variant, which carries a\n`webhook-signature: v1a,\u003cbase64\u003e` entry so a receiver only ever holds the public key.\n\nA producer that uses a different header name needs no scheme class of its own: set\n`signature_headers.signature` and it is injected into any header-overridable scheme (e.g.\n`PlainHmacScheme` for SendCloud's `Sendcloud-Signature`). A key you omit keeps the scheme's\nown default, so `GitHubScheme` keeps `X-Hub-Signature-256`; `StripeScheme`'s header is fixed.\nGenerate a keypair with `php artisan webhooks:ed25519-keygen` or\n`Webhooks\\Core\\Signing\\Ed25519Keys::generate()`. A receiver may pin a static public key\nor point `jwks.url` at the producer's JSON Web Key Set of Ed25519 keys — fetched through\nthe SSRF guard and cached — for rotating provider keys.\n\n**Verification that isn't a signature.** Some providers can't be verified by a pure\nfunction of the bytes: Mollie signs nothing (authenticity is an authenticated API call\nback to it), PayPal verifies through a cert-chain API keyed on a webhook ID, not a secret.\nPoint a config's `verifier` at a `Webhooks\\Client\\Verification\\InboundVerifier` — a\ncontainer-resolved class that receives the `Request` and `WebhookConfig` and returns a\n`VerificationResult`. It takes precedence over `scheme`, makes `secret` optional, and\nleaves the entire rest of the pipeline (rate limit, dedupe, store, dispatch, the\n401-and-store-nothing path) untouched — so a non-HMAC provider gets everything the package\ndoes without you rebuilding a controller. Treat a failed provider callback as *not* valid,\nso an unreachable provider never turns the endpoint into an open write surface.\n\n**Secret rotation** is first-class: `Webhooks\\Core\\Signing\\SecretSet::rotating($current,\n$previous)` (or `-\u003euseSecrets()` on a call) signs with both, so verification never breaks\nmid-rotation.\n\n**Canonical JSON (opt-in).** Set `server.signing.canonicalize` (or `-\u003ecanonicalizeJson()`\nper call) to sign and send a deterministic, sorted-key body, so a receiver that\nre-canonicalizes reproduces the exact signed bytes regardless of key order. Off by\ndefault — the exact bytes you send are already what is signed.\n\n**Published interop vectors.** Known-answer vectors are shipped at\n[`resources/interop/standard-webhooks-vectors.json`](resources/interop/standard-webhooks-vectors.json)\n(with a [format guide](resources/interop/README.md)) so a third-party receiver — or a\nport of the verifier to another language — can prove byte-for-byte compatibility without\ntrusting this package's code. They cover all three cases an implementer needs: the\ncanonical symmetric `v1` example from the specification, an asymmetric **`v1a` Ed25519**\nvector (public key, message, expected signature — Ed25519 signing is deterministic, so a\ncorrect port reproduces it exactly), and **negative** vectors that must *fail* to verify,\nbecause an implementation that accepts everything also passes every positive test. Tests\nin the suite re-verify each shipped vector against the engine, so the published contract\ncan never drift.\n\n## Platform (subscriptions, fan-out, self-service, health, transforms)\n\nThe Platform layer turns the delivery engine into a product. It is on by default; each\ncapability below is individually gated.\n\n**Subscriptions \u0026 fan-out.** Register endpoints per event type and fan an event out to\nevery matching, active subscription:\n\n```php\nuse Webhooks\\Facades\\Webhooks;\nuse Webhooks\\WebhookEvent;\n\n$subscription = Webhooks::subscribe(\n    owner: $team,                            // any Eloquent model, or null for a global endpoint\n    url: 'https://example.com/webhooks',\n    eventTypes: ['invoice.paid'],\n);\n$subscription-\u003esecret;                       // reveal once — it signs their deliveries\n\nWebhookEvent::dispatch('invoice.paid', ['invoice_id' =\u003e 'in_123'], tenant: $team);\n```\n\n\u003e **Owner key type — bigint (default), UUID or ULID.** `owner_id` is denormalized across the\n\u003e subscriptions table, the delivery log and the dashboard rollup, and all three share one storage\n\u003e type. It is `bigint` by default; if your owner models key by UUID or ULID, set\n\u003e `platform.owner_key_type` (`WEBHOOKS_OWNER_KEY_TYPE`) to `uuid` or `ulid` **before migrating** so\n\u003e the columns are rendered to match. `subscribe()` rejects an owner whose key does not fit the\n\u003e configured type up front, with a clear error, rather than failing on the first fan-out. Changing\n\u003e the setting on a populated database is a schema migration, not a runtime toggle; it is independent\n\u003e of `Schema::defaultMorphKeyType()`. A global (`null`) owner works under every setting.\n\nEvent types match exactly by default. Turn on `platform.wildcards` to let a subscription\nlist a **prefix wildcard** — `order.*` receives every event under that prefix, so a concrete\n`order.line.added` reaches subscribers of `order.line.added`, `order.line.*` and `order.*`\n(one prefix per dot). Each arm stays an indexed JSON-containment lookup, so the fan-out is\nstill an index scan.\n\nEach subscriber receives a signed POST whose JSON body is an envelope:\n\n```json\n{\n  \"id\": \"0192…-uuid\",\n  \"type\": \"invoice.paid\",\n  \"created_at\": \"2026-07-01T12:00:00+00:00\",\n  \"data\": { \"invoice_id\": \"in_123\" }\n}\n```\n\nThe `id` is the Standard Webhooks `webhook-id` and is stable across redeliveries, so\nconsumers can deduplicate on it. Supporting operations: `Webhooks::ping($subscription)`\n(a one-off test event), `Webhooks::rotateSecret($subscription)`, and\n`Webhooks::redeliver($delivery)` (a replay that keeps the original event id).\n\nAn optional **event catalog** (`platform.catalog`) documents each type and can carry a\nJSON Schema; enable `platform.validate_payloads` and a non-conforming payload is rejected\nwith `Webhooks\\Exceptions\\InvalidPayloadException` before any delivery is created.\n\n### Self-service portal (opt-in)\n\nA tenant-scoped surface where a customer manages its *own* endpoints — a paginated list\nwith a health badge, a create/edit form that SSRF-vets the URL, a secret panel that\nreveals and rotates the signing secret, an endpoint health matrix, and a\npayload-transform editor. These are **real, full-page screens that ship with the\npackage**, not a headless seam you have to build against.\n\nThree steps, all required:\n\n```bash\n# 1. The portal's screens are Livewire components built from WireKit.\ncomposer require livewire/livewire pushery/wirekit\n```\n\n```php\n// 2. Register the provider — it is NOT auto-registered.\n// bootstrap/providers.php\nWebhooks\\Platform\\SelfServicePortalServiceProvider::class,\n```\n\n```php\n// 3. config/webhooks.php — switch the layer on.\n'platform' =\u003e ['self_service' =\u003e ['enabled' =\u003e true, /* … */]],\n```\n\nEvery query is scoped so a tenant only ever sees the endpoints it owns, guarded by the\n`manage-webhook-endpoints` gate and a row-level `WebhookSubscriptionPolicy`. It mounts at\n`route_prefix` behind `middleware`, `max_endpoints_per_tenant` caps registrations, and the\nviews are publishable (`--tag=webhooks-self-service-views`) if you are on another UI kit\nand want to restyle them.\n\n\u003e **Authorization is fail-closed — you must grant access.** The `manage-webhook-endpoints`\n\u003e gate **denies** until your app defines a `webhooks.manage` ability, so registering the layer\n\u003e never silently opens endpoint management to every authenticated user. Opt a tenant in:\n\u003e\n\u003e ```php\n\u003e Gate::define('webhooks.manage', fn ($user) =\u003e $user-\u003eisAdmin());\n\u003e ```\n\n**Endpoint health scoring (opt-in).** Each active endpoint earns a 0–100 score blended\nfrom its recent success rate, a p95-latency penalty and a consecutive-failure penalty,\nmapped onto a `healthy` / `degraded` / `failing` band (`unknown` with no history). The\nscore comes from a single aggregate query:\n\n```php\nuse Webhooks\\Platform\\Health\\EndpointHealth;\n\n$report = app(EndpointHealth::class)-\u003escoreFor($subscription);\n\n$report-\u003escore;       // 0–100, or null with no history to score\n$report-\u003estatus;      // HealthStatus::Healthy / Degraded / Failing / Unknown\n$report-\u003esuccessRate; // and the raw signals the score was blended from\n$report-\u003ep95;\n$report-\u003esampleSize;\n```\n\n`php artisan webhooks:refresh-endpoint-health` caches the score onto the subscription.\nWith `platform.health.enabled`, a finished delivery also refreshes its own endpoint's\ncached score, and the command is scheduled to sweep every active endpoint.\n\n**Payload transforms \u0026 versioning (opt-in).** With `platform.payload_versioning.enabled`,\nan endpoint may carry a `payload_version` and/or a stored declarative `transform`, and the\nevent data is reshaped for that endpoint *before* the body is signed — so the transformed\nbytes are the signed-and-sent bytes. The `DeclarativePayloadTransformer` is safe and\ndata-driven (no callables): `include` / `exclude` field lists, `rename`, `rewrap`, and a\nstamped `payload_version`. Two endpoints on the same event with different versions\ntherefore receive different bodies.\n\n**Egress allowlist.** `php artisan webhooks:egress-ips` prints the configured\n`core.egress.published_ips` (json/txt/md) for a consumer to allowlist on its firewall, and\nan optional `core.egress.proxy` routes every outbound delivery through a forward proxy.\n\n\u003e **The IP pin does not survive a proxy.** The SSRF guard vets and pins a destination IP for\n\u003e *direct* connections. A forward proxy resolves the hostname itself, so the pin is **not**\n\u003e enforced through it and the anti-rebinding guarantee becomes the proxy's responsibility —\n\u003e your proxy must enforce its own egress control. Leave `core.egress.proxy` unset unless the\n\u003e proxy does that.\n\n**AsyncAPI export.** `php artisan webhooks:asyncapi` builds an AsyncAPI 3.0 document from\nthe event catalog — one channel, operation and message per event type, each carrying the\ntype's schema, example and description (JSON by default, YAML when `symfony/yaml` is\ninstalled).\n\n## Observability (Dashboard)\n\nAn opt-in, customer-facing analytics UI over the delivery log. It reads; it records\nnothing. Three steps, all required:\n\n```bash\n# 1. The panels are Livewire components built from WireKit.\ncomposer require livewire/livewire pushery/wirekit\n```\n\n```php\n// 2. Register the provider — it is NOT auto-registered.\n// bootstrap/providers.php\nWebhooks\\Dashboard\\WebhooksDashboardServiceProvider::class,\n```\n\n```php\n// 3. config/webhooks.php — switch it on. It reads Platform's delivery log,\n//    so the Platform layer must stay enabled (or point 'source_model' at your own).\n'dashboard' =\u003e ['enabled' =\u003e true, /* … */],\n```\n\nThe class-based Livewire 4 panels show KPI cards, a stacked hourly-activity chart (drawn\nserver-side as SVG — no chart library, no compiled JS), latency percentiles\n(P50/P90/P95/P99), a live recent-delivery queue, top event types, an endpoint setup\nsummary, and a sortable, filterable, paginated deliveries table with a detail drawer and\none-click redelivery — on a tabbed full-page component. Access is guarded by a\n`view-webhook-dashboard` gate and a `WebhookDeliveryPolicy`, and every query is\ntenant-scoped.\n\n\u003e **Authorization is fail-closed — you must grant access.** The `view-webhook-dashboard`\n\u003e gate **denies** until your app defines a `webhooks.view` ability, so registering the\n\u003e dashboard never silently exposes the operator surface to every authenticated user. Grant it:\n\u003e\n\u003e ```php\n\u003e Gate::define('webhooks.view', fn ($user) =\u003e $user-\u003eisAdmin());\n\u003e ```\n\nThe read model is an hourly materialized view refreshed by\n`php artisan webhooks:refresh-metrics` (scheduled at `dashboard.metrics.refresh`). The\npage mounts at `dashboard.prefix` behind `dashboard.middleware`; the Blade views are\npublishable (`--tag=webhooks-dashboard-views`) for hosts on another UI kit — publishing\nand restyling them is the supported escape hatch from WireKit. For very high volume,\n`dashboard.percentiles.driver = 'tdigest'` reads percentiles from per-bucket digests\n(requires the PostgreSQL `tdigest` extension — it is not available on MySQL, nor on Neon,\nwhich is what Laravel Cloud's Postgres is). The default `live` driver needs no extension,\nis the same complexity class on both engines, and returns **identical** numbers — so this\ntier is an optimization for extreme volume, not a correctness or an engine-choice matter.\n\n#### Operator mode — observing your global endpoints\n\nThe dashboard is tenant-scoped by default: it reads the endpoints and deliveries owned by the\nacting tenant. If instead you run a handful of **global** endpoints (subscriptions registered with\na `null` owner, which receive every event), set `dashboard.operator = true`\n(`WEBHOOKS_DASHBOARD_OPERATOR=true`). The dashboard then reads exactly those owner-less rows — no\ntenant is resolved, so the resolver is not needed. It never shows one tenant's private rows to\nanother: operator mode is *global rows only*, not *all rows*. Because it shows those rows to\neveryone the `view-webhook-dashboard` gate admits, gate that ability to your operators.\n\n### JSON metrics endpoint (opt-in)\n\nSet `dashboard.expose_json_api` to `true` and the same read model is served as JSON, so you\ncan drive your own charts, a status page or an alerting rule from the dashboard's numbers.\nWhile the flag is `false` (the default) the route is not registered at all.\n\n```\nGET /webhooks/api/metrics?window=24h        # route name: webhooks.dashboard.metrics\n```\n\nIt mounts at `dashboard.api_path` (default `api/metrics`) under `dashboard.prefix`, behind\nthe same `dashboard.middleware` and the same `view-webhook-dashboard` gate as the page, and\nevery read is scoped to the acting tenant — nobody ever reads another tenant's numbers.\n\n| Query parameter | Values                                     | Default                |\n| --------------- | ------------------------------------------ | ---------------------- |\n| `window`        | any token from `dashboard.windows`         | the first one (`24h`)  |\n\nA window the host does not offer is rejected with `422` — it never falls back silently. The\nresponse carries **aggregates only**: no delivery rows, payloads, headers or signing\nmaterial are exposed. Latencies are milliseconds; `retry_rate` is a percentage.\n\n```json\n{\n  \"window\": \"24h\",\n  \"generated_at\": \"2026-01-31T09:15:00+00:00\",\n  \"kpis\": {\n    \"total\": 5,\n    \"delivered\": 3,\n    \"pending\": 1,\n    \"failed\": 1,\n    \"retried\": 1,\n    \"retry_rate\": 20.0,\n    \"p50_ms\": 20.0,\n    \"p90_ms\": 20.0,\n    \"p95_ms\": 20.0,\n    \"p99_ms\": 20.0\n  },\n  \"hourly\": [\n    {\n      \"bucket\": \"2026-01-31T09:00:00+00:00\",\n      \"total\": 5,\n      \"delivered\": 3,\n      \"pending\": 1,\n      \"failed\": 1,\n      \"retried\": 1,\n      \"p50_ms\": 20.0,\n      \"p95_ms\": 20.0\n    }\n  ],\n  \"top_events\": [{ \"event_type\": \"invoice.paid\", \"total\": 5 }]\n}\n```\n\nA separate, single-view **Laravel Pulse** card for your own engineers is available under\n`pulse.enabled` (its provider is not auto-registered and `laravel/pulse` stays a\nsuggestion) — throughput, failure rate and latency of outbound deliveries by event type.\n\n### Querying the tables yourself\n\nIf you write your own health check or report against the package's tables — `WebhookDelivery`,\n`WebhookCall`, `WebhookServerDelivery` — **bind timestamps through the shipped scopes**, never a\nplain `-\u003ewhere('created_at', …)`. Every timestamp column is `timestamptz` (PostgreSQL) or a\nUTC-naive `DATETIME(6)` (MySQL); a naive literal is resolved against the **database session time\nzone**, which is unrelated to `app.timezone` and routinely not UTC — so a hand-bound comparison is\noff by that offset and quietly returns the wrong rows, with no error to notice. A health check\nwritten that way can never fire.\n\nThe scopes bind the instant, per dialect, so you can't get it wrong:\n\n```php\nuse Webhooks\\Models\\WebhookDelivery;\n\n// \"Are deliveries piling up undelivered because no worker is running?\"\n$stuck = WebhookDelivery::query()\n    -\u003ependingSince(now()-\u003esubMinutes(15))\n    -\u003ecount();\n\nWebhookDelivery::query()-\u003ecreatedBefore(now()-\u003esubDay());        // strictly before\nWebhookDelivery::query()-\u003ecreatedAfter($since);                  // at or after\nWebhookDelivery::query()-\u003ecreatedBetween($from, $to);            // half-open [from, to)\nWebhookDelivery::query()-\u003ewhereTimestamp('delivered_at', '\u003e=', $since);  // any column\n```\n\nFor a raw statement the scopes don't cover, `(new WebhookDelivery)-\u003eboundTimestamp($moment)` returns\nthe same offset-correct literal to bind by hand. A raw format constant is deliberately **not**\nexposed: the correct literal differs by engine, so a copied constant would be right on one and wrong\non the other — only a dialect-aware binding is safe.\n\n## Events\n\nEvents are the package's main extension point: this is where you notify an endpoint's\nowner, page on-call, write an audit trail or broadcast a live dashboard. They come in two\nfamilies, and **which one you want depends on one question: do you run the Platform\nlayer?**\n\n**The transport family — `Webhooks\\Server\\Events\\*`.** Dispatched by the delivery engine\nitself, so they fire for **every** delivery, whether it came from `Webhooks::dispatch()`,\na `PendingWebhook` you built by hand, or a redelivery. They are scoped to a single **HTTP\nattempt** and carry the transport's value object (`WebhookDeliveryData`), not a model.\n\n| Event | Fires | Carries |\n| ----- | ----- | ------- |\n| `WebhookDeliveryDispatching` | once per delivery, synchronously, before it is queued | `data` |\n| `WebhookAttemptStarting` | before each HTTP request | `data`, `attempt` |\n| `WebhookAttemptSucceeded` | an attempt returned 2xx (the delivery is done) | `data`, `attempt`, `response` |\n| `WebhookAttemptFailed` | **each** failed attempt — fires again on every retry | `data`, `attempt`, `response?`, `exception?` |\n| `WebhookAttemptRetrying` | a retry has been scheduled | `data`, `attempt`, `delaySeconds` |\n| `WebhookAttemptDeferred` | a `Retry-After` beyond the cap was waited out, off the retry budget | `data`, `attempt`, `delaySeconds`, `requestedSeconds` |\n| `WebhookAttemptsExhausted` | **once**, when the delivery gives up for good | `data`, `attempt`, `response?`, `exception?` |\n\n**The domain family — `Webhooks\\Events\\*`.** Dispatched by the Platform layer as it\nwrites the delivery log, so they fire **only while `platform.enabled` is true**. They are\nscoped to the **delivery**, not the attempt, and carry the Eloquent models.\n\n| Event | Fires | Carries |\n| ----- | ----- | ------- |\n| `WebhookDeliverySucceeded` | the delivery was accepted (2xx) | `delivery` |\n| `WebhookDeliveryFailed` | the delivery exhausted its retries — **once** | `delivery`, `reason` |\n| `WebhookDeliveryRateLimited` | an over-limit delivery was deferred rather than dropped | `delivery`, `delaySeconds` |\n| `WebhookEndpointAutoDisabled` | the circuit breaker disabled an endpoint | `subscription` |\n\nPlus one on each end of the package: `Webhooks\\Client\\Events\\InvalidWebhookSignature`\n(an inbound request failed verification; carries the request, the source config and a\ncoarse reason) and `Webhooks\\Dashboard\\Events\\WebhookRedeliveryRequested` (an operator\nasked the dashboard to replay a delivery).\n\nTwo rules worth pinning up:\n\n- **\"Attempt\" is not \"delivery\".** `WebhookAttemptFailed` fires on every failed try, so a\n  notification wired to it goes out once per retry. The delivery gives up exactly once,\n  and says so as `WebhookAttemptsExhausted` (transport) / `WebhookDeliveryFailed`\n  (domain). Notify from those.\n- **Send-only apps get the transport family only.** With `platform.enabled=false` there\n  is no delivery log and no `Webhooks\\Events\\*` — a listener on them would never fire and\n  nothing would tell you. Listen to `Webhooks\\Server\\Events\\*` instead; they are always\n  dispatched.\n\n```php\n// Platform app: notify the endpoint's owner once, when the delivery is dead.\nEvent::listen(function (Webhooks\\Events\\WebhookDeliveryFailed $event): void {\n    $event-\u003edelivery-\u003esubscription-\u003eowner?-\u003enotify(new WebhookEndpointFailing($event-\u003ereason));\n});\n\n// Send-only app: the same moment, from the engine itself.\nEvent::listen(function (Webhooks\\Server\\Events\\WebhookAttemptsExhausted $event): void {\n    Log::error('Webhook gave up', ['url' =\u003e $event-\u003edata-\u003eurl, 'attempts' =\u003e $event-\u003eattempt]);\n});\n```\n\n## Operator console (opt-in)\n\nTwo embeddable Livewire components for the screens **you** run — an operator managing\nevery endpoint and browsing the delivery log — as distinct from the customer-facing\nself-service portal and dashboard above. They render inside *your* layout, on *your*\nauthorized pages, rather than mounting routes of their own.\n\n\u003e **These two are unscoped, by design.** They list and mutate **every** tenant's\n\u003e endpoints and deliveries, and the endpoints they register are global (owner-less), so\n\u003e every tenant's events reach them. That is what an operator console is — and it means\n\u003e you must place them behind an operator-only gate. For anything a **customer** touches,\n\u003e use the tenant-scoped, policy-guarded surfaces instead: the\n\u003e [self-service portal](#self-service-portal-opt-in) for managing endpoints, the\n\u003e [dashboard](#observability-dashboard) for the delivery log.\n\n```bash\ncomposer require livewire/livewire\n```\n\n```php\n// bootstrap/providers.php — not auto-registered.\nWebhooks\\WebhooksUiServiceProvider::class,\n```\n\n```blade\n{{-- your own page, behind your own authorization --}}\n\u003clivewire:webhooks.admin.subscriptions /\u003e\n\u003clivewire:webhooks.admin.deliveries /\u003e\n```\n\nThe two components ship in two view variants and you publish **exactly one** — both land at\n`resources/views/vendor/webhooks/livewire` and the second would overwrite the first:\n\n```bash\nphp artisan vendor:publish --tag=webhooks-ui           # neutral Tailwind stubs\nphp artisan vendor:publish --tag=webhooks-ui-wirekit   # WireKit-styled stubs\n```\n\nPublishing is optional — the package's own views render as-is (the neutral variant needs\nno design system; the WireKit variant needs `pushery/wirekit`). Publish when you want to\nrestyle them, and treat the stubs as a starting point you own.\n\n### Publishable tags\n\n| Tag | Publishes |\n| --- | --- |\n| `webhooks-config` | `config/webhooks.php` |\n| `webhooks-migrations` | The Platform migrations — subscriptions + the delivery log |\n| `webhooks-client-migrations` | The Client migration — `webhook_calls` |\n| `webhooks-server-migrations` | The standalone-persistence migration — `webhook_server_deliveries` |\n| `webhooks-dashboard-migrations` | The dashboard's hourly materialized view |\n| `webhooks-lang` | The translation files (seven locales), to override a string or add another |\n| `webhooks-views` | The shared views (including the `webhooks::pagination` control) |\n| `webhooks-dashboard-views` | The observability dashboard's views |\n| `webhooks-self-service-views` | The self-service portal's views |\n| `webhooks-ui` | The operator console, neutral Tailwind variant |\n| `webhooks-ui-wirekit` | The operator console, WireKit variant |\n\nThere is **one migration tag per layer**, and each publishes its files flat into\n`database/migrations` — where the migrator actually looks. Publish only the layers you\nswitched on: a published migration *runs*, so publishing all of them would create tables\nfor layers you never enabled.\n\n## Styling the UI\n\n**Required for every screen this package renders** — the dashboard, the self-service\nportal and both publishable stubs.\n\nThe package ships **no compiled stylesheet**. Its views are Tailwind utilities over\nWireKit's design tokens: `@wirekitStyles` brings the tokens, and **your** Tailwind build\ncompiles the utilities that consume them. That build has to be told where to look — for\nWireKit's components *and* for this package's views. Both source registrations are\nrequired; with either one missing the screens render unstyled.\n\n```css\n/* resources/css/app.css */\n@import 'tailwindcss';\n\n/* This package's views. */\n@import '../../vendor/pushery/webhooks-for-laravel/resources/css/webhooks.css';\n\n/* WireKit's components (required by every WireKit consumer — see its install notes). */\n@source '../../vendor/pushery/wirekit/resources/views/**/*.blade.php';\n```\n\nInstall the icon set the screens draw their empty states and primary actions against.\nWithout it WireKit renders an inert placeholder where each icon would be — the pages\nstill work, they simply lose their iconography:\n\n```bash\ncomposer require blade-ui-kit/blade-icons blade-ui-kit/blade-heroicons\n```\n\n**Dark mode.** WireKit's dark tokens live behind a `.dark` class on the document root.\nBecause the dashboard and the portal are the *package's* layouts, the package puts it\nthere: `ui.theme` is `auto` by default, which mirrors the reader's system preference (and\nkeeps mirroring it if they change it). Pin it with `WEBHOOKS_UI_THEME=light` or `=dark` —\nwhich is also how you switch off the small inline head script under a strict\nContent-Security-Policy. Two package-level custom properties retune the plot heights on\nthe dashboard without forking a view: `--wh-chart-height` and `--wh-sparkline-height`.\n\nIf you are on another UI kit entirely, publish the views (`--tag=webhooks-views`,\n`--tag=webhooks-dashboard-views`, `--tag=webhooks-self-service-views`) and restyle them;\nthe pagination control (`webhooks::pagination`) publishes with them.\n\n### Embedding in an app with its own asset pipeline and a strict CSP\n\nThe shipped layouts emit only WireKit's tokens (`@wirekitStyles`), so an app with its own\nVite build has nowhere to load **its** compiled CSS — and its `@source` glob (above) has to\nreach `vendor/` for the utilities to build at all. Rather than publishing and forking the\nlayout, point `ui.assets` at a Blade partial and the layouts `@include` it in `\u003chead\u003e`:\n\n```php\n// config/webhooks.php\n'ui' =\u003e [\n    'assets' =\u003e 'webhooks-assets',   // resources/views/webhooks-assets.blade.php: @vite(['resources/css/app.css'])\n],\n```\n\nUnder a **strict Content-Security-Policy**, the one inline script the layouts emit — the\n`theme = 'auto'` dark-mode mirror — is blocked unless it carries a nonce. Either pin the\ntheme (`WEBHOOKS_UI_THEME=light`/`dark`) to drop the script entirely, or keep `auto` and give\nit a nonce your policy allows. Because a per-request nonce is a closure and a closure in config\nbreaks `php artisan config:cache`, register it from a service provider instead of the config\nfile:\n\n```php\n// A service provider's boot():\nuse Webhooks\\Support\\UiTheme;\n\nUiTheme::resolveNonceUsing(fn () =\u003e \\Illuminate\\Support\\Facades\\Vite::cspNonce());\n```\n\n(`config('webhooks.ui.csp_nonce')` still accepts a **static** string for the rare case a fixed\nnonce is enough.)\n\n## Configuration\n\nEvery option is documented inline in `config/webhooks.php`. The section tree:\n\n| Section     | Gate (default)                              | Contents                                                                                     |\n| ----------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- |\n| `core`      | always on                                   | `signing.scheme`, the `ssrf` policy, the `egress` allowlist + proxy                          |\n| `server`    | `server` (on) — **forced on by `platform`** | queue/connection, `signing` (canonicalize, ed25519), `http_verb`, timeouts, `tries`, `backoff`, `no_retry_on_4xx`, `persistence`, `large_payload`, `verify_ssl`, `horizon_tags` |\n| `platform`  | `platform` (on)                             | `catalog`, `validate_payloads`, `circuit_breaker`, `rate_limit`, retention/partitioning, `self_service`, `health`, `payload_versioning` |\n| `client`    | `client.enabled` (**off**)                  | `raw_body_capture`, per-source `configs`, `delete_after_days`                                 |\n| `dashboard` | `dashboard.enabled` (**off**) — needs `platform` | route `prefix`/`middleware`, `source_model`, `windows`, `poll_interval`, `percentiles`, `metrics.refresh`, `expose_json_api` + `api_path` |\n| `pulse`     | `pulse.enabled` (**off**)                   | the internal-ops Pulse card                                                                   |\n| `search`    | `search.enabled` (**off**)                  | optional Laravel Scout full-text index over the delivery/call logs                           |\n| `otel`      | `otel.enabled` (**off**)                    | a dependency-free OpenTelemetry span seam per finished delivery                               |\n| `ui`        | always on                                   | `theme` (`auto` / `light` / `dark`) for the package's own full-page layouts                    |\n\nSub-feature switches default off: `server.persistence.enabled`,\n`server.signing.canonicalize`, `server.signing.ed25519.enabled`, `core.egress.enabled`,\n`platform.self_service.enabled`, `platform.health.enabled`,\n`platform.payload_versioning.enabled`.\n\nEvery `server` key is the **default for each outbound call**; the `PendingWebhook` builder\noverrides any of them per delivery. Turning on `server.signing.ed25519.enabled` switches\n*every* delivery to the asymmetric `v1a` signature made with\n`server.signing.ed25519.secret_key` — the per-endpoint shared secret then plays no part,\nand each receiver verifies with the public key alone.\n\n## Localization\n\nEvery string the shipped UI puts in front of a user — headings, labels, placeholders,\nbuttons, empty states, toasts, validation messages, status badges and the accessible names\na screen reader announces — is translated, never hardcoded. The package ships **seven\nlanguages** — English, German, Spanish, French, Italian, Dutch and Portuguese — and\ntranslations resolve under the `webhooks` namespace, one file per surface:\n\n| File | Surface |\n| --- | --- |\n| `dashboard` | the observability dashboard |\n| `self-service` | the endpoint portal |\n| `management` | the [operator console](#operator-console-opt-in) (both stub variants) |\n| `pulse` | the Laravel Pulse card |\n\n```php\n__('webhooks::dashboard.table.replay');\n__('webhooks::self-service.form.register');\n__('webhooks::management.form.submit');\n```\n\nThe rendered locale is simply the host app's (`app()-\u003egetLocale()`), so nothing needs\nconfiguring — set the locale as you already do and the UI follows. Status and health values\nare translated for display only; what is persisted (`succeeded`, `failed`, `degraded`, …)\nnever changes. The endpoint form carries its own validation messages, so a refused save\nreads correctly even on a host that never translated Laravel's own validation lines.\n\n### Overriding a string\n\nPublish the translations and edit them in your app:\n\n```bash\nphp artisan vendor:publish --tag=webhooks-lang\n```\n\nThey land in `lang/vendor/webhooks/{locale}/`, where the host's file wins over the\npackage's for every key it defines — copy only the keys you want to change.\n\n### Adding a locale\n\nPublish as above, copy `lang/vendor/webhooks/en` to your locale's directory\n(`lang/vendor/webhooks/fr`, say) and translate the values, leaving the keys untouched. Any\nkey you leave out falls back to the package's English, so a partial translation still\nrenders — and you can fill the rest in later.\n\n## Security\n\nWebhook URLs are attacker-influenced, so every endpoint is validated when it is\nregistered **and** again immediately before each delivery, with the connection pinned to\nthe validated IP so a rebinding DNS record cannot redirect it. Private, loopback,\nlink-local, unique-local, carrier-grade-NAT, multicast and cloud-metadata\n(`169.254.169.254`) addresses are refused, redirects are not followed, and TLS\nverification stays on. Signing secrets are stored encrypted at rest; inbound sensitive\nheaders (`Authorization`, `Cookie`, plus your `redact` list) are masked before storage.\n\n\u003e ⚠️ **`core.ssrf.allowed_hosts` is an opt-*out*, not an allowlist.** A host you put on\n\u003e it skips DNS resolution, skips the private/loopback/metadata IP classification **and\n\u003e skips the IP pin** — so that host may resolve into your internal network and its DNS\n\u003e record may rebind between the check and the connection. Use it only for a known\n\u003e internal endpoint whose risk you accept. The *restrictive* list is\n\u003e `core.ssrf.blocked_hosts`. And `core.ssrf.block_private_networks = false` switches the\n\u003e guard off globally — leave it `true`.\n\nReport vulnerabilities per the [security policy](SECURITY.md), privately.\n\n## Reliability\n\n- **Retries \u0026 backoff** — exponential with full jitter, capped, Retry-After-aware\n  (`server.tries`, `server.backoff`). `no_retry_on_4xx` keeps a permanent `400`/`410`\n  from being retried for hours while `408`/`425`/`429` still are.\n- **Idempotency** — a stable per-event id (the Standard Webhooks `webhook-id`), preserved\n  across redelivery, and two-tier inbound dedupe on receipt.\n- **Circuit breaker** — after `platform.circuit_breaker.threshold` consecutive final\n  failures an endpoint auto-disables and a `Webhooks\\Events\\WebhookEndpointAutoDisabled`\n  event fires; a single success resets it.\n- **Rate limiting** — a per-subscription outbound cap (`platform.rate_limit`) and an\n  optional per-source inbound cap keep one slow endpoint from starving the queue. The\n  outbound cap SHAPES traffic rather than dropping it: an over-limit delivery is logged,\n  announced (`Webhooks\\Events\\WebhookDeliveryRateLimited`) and enqueued with a delay, so a\n  burst is spread across the following minutes instead of being lost.\n- **Retention** — `php artisan webhooks:partition-maintenance` (scheduled daily) ages the\n  delivery log out past `platform.retention_months`. **On PostgreSQL** the log is monthly\n  range-partitioned, so a whole old month is dropped as a metadata operation, not a bulk\n  `DELETE`, and the command also provisions upcoming partitions and drains any delivery that\n  landed in the catch-all default partition while the schedule was behind — so a lapse in the\n  cron cannot permanently stop either. **On MySQL** the log is a flat table (kept so the\n  `ON DELETE CASCADE` GDPR guarantee holds — MySQL cannot have both a foreign key and\n  partitioning), and retention is an **indexed, chunked `DELETE`** over the same window; the\n  same command, the same cutoff. Below ~1M deliveries/month the difference is invisible; see\n  [Choosing your database](#choosing-your-database).\n- **Lifecycle events** — every delivery announces its fate. Which family to listen to\n  depends on whether you run the Platform layer; see [Events](#events).\n\n## Upgrading from 0.x\n\nVersion 1.0.0 is a ground-up rewrite; treat it as a new major.\n\n- **No third-party webhook-engine dependency.** The delivery engine is now entirely\n  in-house. Sending moved to the fluent `Webhooks\\Server\\PendingWebhook` builder.\n- **Standard Webhooks is now the default signature.** Deliveries carry\n  `webhook-id` / `webhook-timestamp` / `webhook-signature: v1,\u003cbase64\u003e` over\n  `{id}.{timestamp}.{rawBody}` — replacing 0.x's single\n  `Webhook-Signature: t=\u003cunix\u003e,v1=\u003chex\u003e` header. Any Standard Webhooks SDK verifies them.\n  **Your existing consumers keep working:** that 0.x dialect ships on as the\n  `Webhooks\\Core\\Signing\\StripeStyleScheme` **receive** adapter, so an app that receives\n  0.x-format traffic verifies it by setting `scheme =\u003e StripeStyleScheme::class` on the\n  source. (Do **not** reach for `StripeScheme` — it pins Stripe's own\n  `Stripe-Signature` header and will not verify a `Webhook-Signature` one.)\n- **`Webhooks\\Signing\\SignatureVerifier` is removed.** The 0.x helper you shipped to\n  consumers is gone: a consumer now verifies with any Standard Webhooks SDK (in any\n  language), or, while it is still on the old format, with `StripeStyleScheme` during the\n  migration window.\n- **Config was reorganized** under `core` / `server` / `platform` / `client` /\n  `dashboard` (plus `pulse` / `search` / `otel`). Re-publish `config/webhooks.php` and\n  move your settings into the new tree; the old flat keys are gone.\n- **New capabilities:** inbound receiving, a self-service portal, endpoint health scoring,\n  per-endpoint payload transforms, and an observability dashboard — all opt-in.\n- **Migrations were recut.** Re-publish and review the migrations before upgrading a\n  populated database.\n\nBecause this is a new major, review your integration end to end rather than expecting a\ndrop-in bump.\n\n## Coming from spatie/laravel-webhook-server or -webhook-client\n\nThis package is a superset of both spatie webhook packages, on one engine or the other —\n**including MySQL**, which is where most of their apps run (`webhook-server` needs no\ndatabase at all; `webhook-client` ships a flat, engine-agnostic `webhook_calls` table). You\ndo not have to switch databases to adopt it.\n\n**From `spatie/laravel-webhook-server` (sending).** Replace the `WebhookCall::create()`\nbuilder with `Webhooks\\Server\\PendingWebhook` (or the `WebhookSender` facade). Signing is\n[Standard Webhooks](#signatures--interop) by default; if your consumers verify the older\n`Signature` header spatie sent, keep sending it by setting the scheme per call. Backoff,\n`Retry-After`, per-call timeouts, SSRF pinning and mutual TLS are all built in — see\n[Sending](#sending-server-layer). No table to migrate: sending is stateless.\n\n**From `spatie/laravel-webhook-client` (receiving).** Replace their route +\n`ProcessWebhookJob` with `Route::webhooks('your/path', 'source-name')` and a config profile\n— see [Receiving](#receiving-client-layer). Ours verifies, **de-duplicates on receipt**\n(two-tier), redacts headers, offloads over-sized bodies and keeps the exact received bytes\nso `hash('sha256', $call-\u003ebody()) === $call-\u003ebody_sha256`. Our `webhook_calls` table is a\nstrict superset of theirs, with one correctness fix worth naming: **their table prunes on an\nunindexed `created_at`; ours ships the index** (so retention stays cheap as the log grows).\n\n**Backfilling their history.** New receipts flow into this package's `webhook_calls` from\nthe moment you switch the route over — nothing else is required to go live. Carrying the\n**old** spatie `webhook_calls` rows across is a one-time copy, and `php artisan\nwebhooks:import-spatie-calls` does it for you. Their columns map onto ours as\n`name → source`, `payload → payload`, `headers → headers`, `exception → exception`, with the\noriginal timestamps preserved. Point it at the source with `--from-table` and\n`--from-connection` — both spatie tables are also named `webhook_calls`, so a same-database\nimport needs one of these to name the source distinctly — preview the counts with `--dry-run`,\nand re-run it as often as you like: each imported row's primary key is derived deterministically\nfrom its source, so a second run imports nothing new. One caveat sets the expectation: spatie\nstored only the parsed `payload`, never the raw received bytes, so an imported row cannot carry\nthe producer's original `body_sha256` — the command reconstructs a self-consistent one from the\nre-encoded payload and writes each row in a terminal state (`processed`, or `failed` when spatie\nrecorded an exception), never re-dispatching a handler over months-old history. Treat imported\nrows as historical records, not re-verifiable ones.\n\n## Testing your integration\n\nEvery delivery is a queued job, so `Bus::fake()` asserts the fan-out without a network\ncall:\n\n```php\nuse Illuminate\\Support\\Facades\\Bus;\nuse Webhooks\\Server\\Jobs\\CallWebhookJob;\nuse Webhooks\\WebhookEvent;\n\nBus::fake();\n\nWebhookEvent::dispatch('invoice.paid', ['invoice_id' =\u003e 'in_123'], tenant: $team);\n\nBus::assertDispatched(CallWebhookJob::class);\n```\n\nUse `-\u003edispatchSync()` on a `PendingWebhook` when you want the request to actually go out\n(against a fake HTTP server, say), and drive the inbound side by posting a request signed\nwith `Webhooks\\Core\\Signing\\StandardWebhooksScheme` — the same class the sender uses, so\na first-party round trip is testable end to end.\n\nWorking on the package itself? Its own suite runs against a real **PostgreSQL 13+**\ndatabase (there is no SQLite fallback): `createdb webhooks_for_laravel_test`, then point\n`DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` at it if it is not\n`postgres@127.0.0.1:5432`. The cross-engine parity suites additionally drive a real **MySQL\n8.4** and a **MariaDB** server (the MariaDB one proves the guard rejects it), so those run\nlocally; see the development notes in the repository.\n\n## Versioning\n\nThis package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and it\ntells you exactly which classes that promise covers.\n\n**The public API is:**\n\n- `WebhookManager` and the `Webhooks` facade, and `WebhookEvent`\n- Sending: `Server\\PendingWebhook` and `Server\\Facades\\WebhookSender`, the backoff\n  contract, the queued `CallWebhookJob`, and the delivery value objects the events carry\n- Receiving: `Client\\WebhookProcessor`, `Client\\WebhookConfig`, `Client\\InboundMessage`,\n  `Client\\Jobs\\ProcessWebhookJob` and the profile / response contracts\n- Signing: the `Core\\Signing\\SignatureScheme` contract, every shipped scheme, and\n  `SecretSet` / `WebhookMessage` / `SignatureHeaders` / `VerificationResult`\n- The SSRF guard contract, the models, the enums, the exceptions, and **all the\n  [events](#events)**\n- The migration database guards `Database\\DatabaseRequirement` and `Database\\PostgresRequirement`\n  (a published migration copy calls one of them), and the `webhooks.database.connection` key\n- The service providers, the Livewire component aliases, the published views and\n  migrations, the config tree, and the publish tags\n\n**Everything else is `@internal`** — the delivery pipeline, the response classifier, the\nconfig reader, the SSRF resolver internals, the dashboard's metric objects, the\nlisteners — and **may change in a minor release**. Those classes carry an `@internal`\ndocblock tag, your IDE and static analyser will say so, and a test in this repository\nfails if a class outside the list above ever loses the tag. Bind to the list; leave the\nrest to the engine.\n\n## Built by Pushery\n\nThis package is built and maintained by [Pushery](https://www.pushery.com) — a\nBerlin-based studio building Laravel applications, SaaS products, and open-source\ntools.\n\nBuilding a Laravel UI? [WireKit](https://wirekit.app), Pushery's open-source\nLivewire component kit, gives you a polished component library out of the box.\nBrowse the rest of our work at [pushery.com](https://www.pushery.com).\n\n## License\n\nThe MIT License (MIT). See [LICENSE](LICENSE) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpushery%2Fwebhooks-for-laravel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpushery%2Fwebhooks-for-laravel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpushery%2Fwebhooks-for-laravel/lists"}