From 06b57b4dadf8f4ddc29a29574c89e72e83e67d80 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 17:39:34 +0100 Subject: [PATCH 1/8] fix: Serve the incoming address, and keep the bytes it arrives as The model hands every incoming webhook a URL at /webhooks/ingress/{token} and the package never routed it, so the address answered 404 unless the application registered it by hand. It cannot sit behind the authenticated stack either, because the sender is a third party holding nothing but the token. Each delivery stored only the parsed body, so the bytes a signature was computed over were gone. Nothing could re-check that signature afterwards, and anything the event was forwarded to would receive a re-encoding that no longer matched. Senders that sign their payloads are the common case, not the exception. Dispatch also asks for one owner's active outgoing hooks on every event it sends, and direction and is_active were indexed separately. Each holds two values, so neither narrowed anything. With a thousand hooks on one owner, twenty of them outgoing, it read 212 rows to find them. It now reads 21. --- CHANGELOG.md | 3 +- config/webhooks.php | 5 ++ ...024_01_01_000012_create_webhooks_table.php | 8 ++- ..._01_000013_create_webhook_events_table.php | 4 ++ routes/ingress.php | 7 ++ src/Http/Controllers/WebhookController.php | 1 + src/Models/WebhookEvent.php | 1 + src/WebhooksServiceProvider.php | 11 +++ tests/Feature/IngressTest.php | 68 +++++++++++++++++++ 9 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 routes/ingress.php create mode 100644 tests/Feature/IngressTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c705695..8d51f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [1.0.0] - 2026-06-16 -- Incoming webhook management: token-protected ingress URLs with payload and header logging. +- Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL. +- Each incoming delivery keeps the bytes exactly as they arrived alongside the parsed body, so a signature computed over them can still be checked later. - Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries. - Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures. - Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator. diff --git a/config/webhooks.php b/config/webhooks.php index 4038d0e..8b5ec8d 100644 --- a/config/webhooks.php +++ b/config/webhooks.php @@ -15,6 +15,11 @@ 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], + // What guards the incoming address. A sender is a third party with only the + // token in the URL, so an authenticated stack here means no webhook ever + // arrives. Throttling belongs in this list. + 'ingress_middleware' => [], + /* |-------------------------------------------------------------------------- | Outbound Delivery diff --git a/database/migrations/2024_01_01_000012_create_webhooks_table.php b/database/migrations/2024_01_01_000012_create_webhooks_table.php index f19651a..a23fe15 100644 --- a/database/migrations/2024_01_01_000012_create_webhooks_table.php +++ b/database/migrations/2024_01_01_000012_create_webhooks_table.php @@ -36,8 +36,12 @@ public function up(): void $table->timestamps(); $table->softDeletes(); - $table->index(['is_active']); - $table->index(['direction']); + // Dispatch asks for one owner's active outgoing hooks on every event + // it sends. Indexing direction or is_active on their own is close to + // useless: each holds two values, so neither narrows anything and the + // planner ignores both. + $table->index(['owner_type', 'owner_id', 'direction', 'is_active'], 'webhooks_owner_dispatch'); + $table->index(['direction', 'is_active']); }); } diff --git a/database/migrations/2024_01_01_000013_create_webhook_events_table.php b/database/migrations/2024_01_01_000013_create_webhook_events_table.php index 48c52fd..312e5b6 100644 --- a/database/migrations/2024_01_01_000013_create_webhook_events_table.php +++ b/database/migrations/2024_01_01_000013_create_webhook_events_table.php @@ -18,6 +18,10 @@ public function up(): void $table->foreignId('webhook_id')->constrained()->onDelete('cascade'); } + // The bytes exactly as they arrived. A signature is computed over + // these, so a parsed and re-encoded copy cannot verify against it, + // and neither can anything this event is later forwarded to. + $table->longText('raw_payload')->nullable(); $table->json('payload')->nullable(); $table->json('headers')->nullable(); $table->integer('response_status')->nullable(); diff --git a/routes/ingress.php b/routes/ingress.php new file mode 100644 index 0000000..317353b --- /dev/null +++ b/routes/ingress.php @@ -0,0 +1,7 @@ +name('webhooks.ingress'); diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index 3bd8bd7..69e305a 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -192,6 +192,7 @@ public function ingress(Request $request, string $token): JsonResponse WebhookEvent::create([ 'webhook_id' => $webhook->id, + 'raw_payload' => $request->getContent(), 'payload' => $request->all(), 'headers' => collect($request->headers->all()) ->except(['authorization', 'cookie']) diff --git a/src/Models/WebhookEvent.php b/src/Models/WebhookEvent.php index a674d9e..acb61ea 100644 --- a/src/Models/WebhookEvent.php +++ b/src/Models/WebhookEvent.php @@ -14,6 +14,7 @@ class WebhookEvent extends Model protected $fillable = [ 'webhook_id', + 'raw_payload', 'payload', 'headers', 'response_status', diff --git a/src/WebhooksServiceProvider.php b/src/WebhooksServiceProvider.php index 09280e7..f6499b7 100644 --- a/src/WebhooksServiceProvider.php +++ b/src/WebhooksServiceProvider.php @@ -40,5 +40,16 @@ protected function registerRoutes(): void ], function () { $this->loadRoutesFrom(__DIR__ . '/../routes/webhooks.php'); }); + + // The address an incoming webhook is told to post to. It cannot sit + // behind the authenticated stack, because the sender is a third party + // holding nothing but the token in the URL. Every consumer was + // registering this by hand or the URL the model hands out answered 404. + Route::group([ + 'prefix' => config('webhooks.route_prefix', 'api'), + 'middleware' => config('webhooks.ingress_middleware', []), + ], function () { + $this->loadRoutesFrom(__DIR__ . '/../routes/ingress.php'); + }); } } diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php new file mode 100644 index 0000000..ff3226d --- /dev/null +++ b/tests/Feature/IngressTest.php @@ -0,0 +1,68 @@ +incomingWebhook(); + + // Key order and spacing are part of what a signature covers, so the + // stored copy has to be the sender's bytes rather than a re-encoding. + $raw = '{"zeta":1, "alpha":{"nested":true}}'; + + $this->call( + 'POST', + "/webhooks/ingress/{$webhook->token}", + [], [], [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], + $raw, + )->assertStatus(202); + + $event = WebhookEvent::firstOrFail(); + + $this->assertSame($raw, $event->raw_payload); + $this->assertSame(hash_hmac('sha256', $raw, 'a-secret'), hash_hmac('sha256', $event->raw_payload, 'a-secret')); + } + + #[Test] + public function the_parsed_payload_is_still_there_to_read(): void + { + $webhook = $this->incomingWebhook(); + + $this->call( + 'POST', + "/webhooks/ingress/{$webhook->token}", + [], [], [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], + '{"action":"opened"}', + )->assertStatus(202); + + $this->assertSame('opened', WebhookEvent::firstOrFail()->payload['action']); + } + + private function incomingWebhook(): Webhook + { + $user = User::forceCreate([ + 'name' => 'Owner', + 'email' => 'owner+'.uniqid().'@example.com', + 'password' => bcrypt('secret'), + ]); + + return Webhook::create([ + 'owner_type' => $user->getMorphClass(), + 'owner_id' => $user->getKey(), + 'name' => 'Inbound', + 'direction' => Webhook::DIRECTION_INCOMING, + 'is_active' => true, + ]); + } +} From d83324550153e18f4ea70b6133f9d870fbff152a Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 17:41:53 +0100 Subject: [PATCH 2/8] style: Wrap the ingress test arguments the way pint wants --- tests/Feature/IngressTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php index ff3226d..ed3b7b4 100644 --- a/tests/Feature/IngressTest.php +++ b/tests/Feature/IngressTest.php @@ -22,7 +22,9 @@ public function an_incoming_delivery_keeps_the_bytes_it_arrived_as(): void $this->call( 'POST', "/webhooks/ingress/{$webhook->token}", - [], [], [], + [], + [], + [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], $raw, )->assertStatus(202); @@ -41,7 +43,9 @@ public function the_parsed_payload_is_still_there_to_read(): void $this->call( 'POST', "/webhooks/ingress/{$webhook->token}", - [], [], [], + [], + [], + [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], '{"action":"opened"}', )->assertStatus(202); From a0f6171a17955e4c8c4fcbf2e45c543d55a0ddeb Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 22:24:18 +0100 Subject: [PATCH 3/8] fix: Store the incoming body once, as the bytes it arrived as Keeping the parsed copy alongside the raw one was the same data twice. The parsed form is derived on read instead, so there is one column and nothing is duplicated. It cannot go the other way round. Decoding and re-encoding does not reproduce what was sent: spacing, unicode escaping, slashes, float formatting and large integers all shift, and any of those breaks a signature computed over the original. --- CHANGELOG.md | 2 +- ..._01_000013_create_webhook_events_table.php | 11 +++--- src/Http/Controllers/WebhookController.php | 3 +- src/Models/WebhookEvent.php | 34 +++++++++++++++++-- tests/Feature/IngressTest.php | 29 +++++++++++++--- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d51f24..72e04be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [1.0.0] - 2026-06-16 - Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL. -- Each incoming delivery keeps the bytes exactly as they arrived alongside the parsed body, so a signature computed over them can still be checked later. +- Each incoming delivery is stored as the bytes exactly as they arrived, so a signature computed over them can still be checked later; `decoded()` reads it as data. - Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries. - Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures. - Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator. diff --git a/database/migrations/2024_01_01_000013_create_webhook_events_table.php b/database/migrations/2024_01_01_000013_create_webhook_events_table.php index 312e5b6..b7467e3 100644 --- a/database/migrations/2024_01_01_000013_create_webhook_events_table.php +++ b/database/migrations/2024_01_01_000013_create_webhook_events_table.php @@ -18,11 +18,12 @@ public function up(): void $table->foreignId('webhook_id')->constrained()->onDelete('cascade'); } - // The bytes exactly as they arrived. A signature is computed over - // these, so a parsed and re-encoded copy cannot verify against it, - // and neither can anything this event is later forwarded to. - $table->longText('raw_payload')->nullable(); - $table->json('payload')->nullable(); + // The bytes exactly as they arrived, and only those. A signature is + // computed over them, and decoding then re-encoding does not + // reproduce them: spacing, unicode escaping, slashes, float + // formatting and large integers all shift. Storing the parsed form + // as well would be the same data twice, so it is derived on read. + $table->longText('payload')->nullable(); $table->json('headers')->nullable(); $table->integer('response_status')->nullable(); $table->timestamp('processed_at')->nullable(); diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index 69e305a..1442e2f 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -192,8 +192,7 @@ public function ingress(Request $request, string $token): JsonResponse WebhookEvent::create([ 'webhook_id' => $webhook->id, - 'raw_payload' => $request->getContent(), - 'payload' => $request->all(), + 'payload' => $request->getContent(), 'headers' => collect($request->headers->all()) ->except(['authorization', 'cookie']) ->toArray(), diff --git a/src/Models/WebhookEvent.php b/src/Models/WebhookEvent.php index acb61ea..a4b6aed 100644 --- a/src/Models/WebhookEvent.php +++ b/src/Models/WebhookEvent.php @@ -6,6 +6,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Whilesmart\Webhooks\Concerns\HasConfigurableKey; +/** + * @property int|string $webhook_id + * @property string|null $payload the body exactly as it arrived + * @property array|null $headers + * @property int|null $response_status + */ class WebhookEvent extends Model { use HasConfigurableKey; @@ -14,7 +20,6 @@ class WebhookEvent extends Model protected $fillable = [ 'webhook_id', - 'raw_payload', 'payload', 'headers', 'response_status', @@ -23,13 +28,38 @@ class WebhookEvent extends Model ]; protected $casts = [ - 'payload' => 'array', 'headers' => 'array', 'response_status' => 'integer', 'processed_at' => 'datetime', 'created_at' => 'datetime', ]; + /** + * The payload as data, worked out from the bytes rather than stored twice. + * + * Returns null when the body is not something we can read, which is not an + * error: the bytes are still there to be read by whatever understands them. + */ + public function decoded(): ?array + { + $raw = $this->payload; + + if (! is_string($raw) || trim($raw) === '') { + return null; + } + + $json = json_decode($raw, true); + + if (is_array($json)) { + return $json; + } + + // Some senders post a form rather than a document. + parse_str($raw, $form); + + return $form === [] ? null : $form; + } + protected static function boot() { parent::boot(); diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php index ed3b7b4..c3cd364 100644 --- a/tests/Feature/IngressTest.php +++ b/tests/Feature/IngressTest.php @@ -31,12 +31,15 @@ public function an_incoming_delivery_keeps_the_bytes_it_arrived_as(): void $event = WebhookEvent::firstOrFail(); - $this->assertSame($raw, $event->raw_payload); - $this->assertSame(hash_hmac('sha256', $raw, 'a-secret'), hash_hmac('sha256', $event->raw_payload, 'a-secret')); + $this->assertSame($raw, $event->payload); + $this->assertSame( + hash_hmac('sha256', $raw, 'a-secret'), + hash_hmac('sha256', $event->payload, 'a-secret'), + ); } #[Test] - public function the_parsed_payload_is_still_there_to_read(): void + public function the_payload_can_still_be_read_as_data(): void { $webhook = $this->incomingWebhook(); @@ -50,7 +53,25 @@ public function the_parsed_payload_is_still_there_to_read(): void '{"action":"opened"}', )->assertStatus(202); - $this->assertSame('opened', WebhookEvent::firstOrFail()->payload['action']); + $this->assertSame('opened', WebhookEvent::firstOrFail()->decoded()['action']); + } + + #[Test] + public function a_form_encoded_delivery_reads_as_data_too(): void + { + $webhook = $this->incomingWebhook(); + + $this->call( + 'POST', + "/webhooks/ingress/{$webhook->token}", + [], + [], + [], + ['CONTENT_TYPE' => 'application/x-www-form-urlencoded', 'HTTP_ACCEPT' => 'application/json'], + 'action=opened&number=7', + )->assertStatus(202); + + $this->assertSame('opened', WebhookEvent::firstOrFail()->decoded()['action']); } private function incomingWebhook(): Webhook From 0e940de0b290c4dd862f0991f6c0be509dcc9d51 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 1 Oct 2026 23:43:31 +0100 Subject: [PATCH 4/8] fix: Preserve raw webhook bodies in a json envelope --- CHANGELOG.md | 2 +- README.md | 10 ++++++ ..._01_000013_create_webhook_events_table.php | 7 +---- src/Http/Controllers/WebhookController.php | 8 ++++- src/Models/WebhookEvent.php | 28 ++++++++++++----- tests/Feature/IngressTest.php | 31 +++++++++++++++++-- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e04be..7532b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [1.0.0] - 2026-06-16 - Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL. -- Each incoming delivery is stored as the bytes exactly as they arrived, so a signature computed over them can still be checked later; `decoded()` reads it as data. +- Incoming deliveries keep their original body in a JSON envelope. `rawBody()` restores the original bytes for signature checks; `decoded()` reads the body as data. - Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries. - Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures. - Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator. diff --git a/README.md b/README.md index 4187253..d09fce8 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,16 @@ Incoming webhooks are sent to a unique URL containing a secure token. When a web 3. A `WebhookEvent` is recorded containing the payload and headers. 4. If `whilesmart/activities` is installed, an activity log is automatically created. +The JSON payload stores the original body as text: + +```json +{"raw_body": "{\"event\": \"opened\"}", "encoding": "utf-8"} +``` + +Bodies that are not valid UTF-8 use base64 with `encoding: "base64"`. +Use `$event->rawBody()` to retrieve the original bytes for signature checks, +and `$event->decoded()` to read JSON objects, arrays or form data. + ### Outbound Webhooks Outgoing webhooks deliver your application's events to a customer-supplied URL. Create one with `direction` set to `outgoing`, the destination `url`, and the `subscribed_events` it should receive (omit `subscribed_events` to receive all): diff --git a/database/migrations/2024_01_01_000013_create_webhook_events_table.php b/database/migrations/2024_01_01_000013_create_webhook_events_table.php index b7467e3..48c52fd 100644 --- a/database/migrations/2024_01_01_000013_create_webhook_events_table.php +++ b/database/migrations/2024_01_01_000013_create_webhook_events_table.php @@ -18,12 +18,7 @@ public function up(): void $table->foreignId('webhook_id')->constrained()->onDelete('cascade'); } - // The bytes exactly as they arrived, and only those. A signature is - // computed over them, and decoding then re-encoding does not - // reproduce them: spacing, unicode escaping, slashes, float - // formatting and large integers all shift. Storing the parsed form - // as well would be the same data twice, so it is derived on read. - $table->longText('payload')->nullable(); + $table->json('payload')->nullable(); $table->json('headers')->nullable(); $table->integer('response_status')->nullable(); $table->timestamp('processed_at')->nullable(); diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index 1442e2f..4158660 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -190,9 +190,15 @@ public function ingress(Request $request, string $token): JsonResponse try { $webhook->recordTrigger(); + $raw = $request->getContent(); + $isUtf8 = preg_match('//u', $raw) === 1; + WebhookEvent::create([ 'webhook_id' => $webhook->id, - 'payload' => $request->getContent(), + 'payload' => [ + 'raw_body' => $isUtf8 ? $raw : base64_encode($raw), + 'encoding' => $isUtf8 ? 'utf-8' : 'base64', + ], 'headers' => collect($request->headers->all()) ->except(['authorization', 'cookie']) ->toArray(), diff --git a/src/Models/WebhookEvent.php b/src/Models/WebhookEvent.php index a4b6aed..cb55353 100644 --- a/src/Models/WebhookEvent.php +++ b/src/Models/WebhookEvent.php @@ -8,7 +8,7 @@ /** * @property int|string $webhook_id - * @property string|null $payload the body exactly as it arrived + * @property array|null $payload * @property array|null $headers * @property int|null $response_status */ @@ -28,21 +28,33 @@ class WebhookEvent extends Model ]; protected $casts = [ + 'payload' => 'array', 'headers' => 'array', 'response_status' => 'integer', 'processed_at' => 'datetime', 'created_at' => 'datetime', ]; - /** - * The payload as data, worked out from the bytes rather than stored twice. - * - * Returns null when the body is not something we can read, which is not an - * error: the bytes are still there to be read by whatever understands them. - */ + public function rawBody(): ?string + { + $raw = $this->payload['raw_body'] ?? null; + + if (! is_string($raw)) { + return null; + } + + if (($this->payload['encoding'] ?? 'utf-8') === 'base64') { + $decoded = base64_decode($raw, true); + + return $decoded === false ? null : $decoded; + } + + return $raw; + } + public function decoded(): ?array { - $raw = $this->payload; + $raw = $this->rawBody(); if (! is_string($raw) || trim($raw) === '') { return null; diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php index c3cd364..9b1fb35 100644 --- a/tests/Feature/IngressTest.php +++ b/tests/Feature/IngressTest.php @@ -31,10 +31,11 @@ public function an_incoming_delivery_keeps_the_bytes_it_arrived_as(): void $event = WebhookEvent::firstOrFail(); - $this->assertSame($raw, $event->payload); + $this->assertSame(['raw_body' => $raw, 'encoding' => 'utf-8'], $event->payload); + $this->assertSame($raw, $event->rawBody()); $this->assertSame( hash_hmac('sha256', $raw, 'a-secret'), - hash_hmac('sha256', $event->payload, 'a-secret'), + hash_hmac('sha256', $event->rawBody(), 'a-secret'), ); } @@ -74,6 +75,32 @@ public function a_form_encoded_delivery_reads_as_data_too(): void $this->assertSame('opened', WebhookEvent::firstOrFail()->decoded()['action']); } + #[Test] + public function plain_text_and_binary_bodies_survive_json_storage(): void + { + $webhook = $this->incomingWebhook(); + + foreach (["Text with \"quotes\", café and a newline\n", "\xFF\x00\x80", ''] as $raw) { + $this->call( + 'POST', + "/webhooks/ingress/{$webhook->token}", + [], + [], + [], + ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'], + $raw, + )->assertStatus(202); + + $event = WebhookEvent::orderByDesc('id')->firstOrFail(); + + $this->assertSame($raw, $event->rawBody()); + $this->assertSame( + hash_hmac('sha256', $raw, 'a-secret'), + hash_hmac('sha256', $event->rawBody(), 'a-secret'), + ); + } + } + private function incomingWebhook(): Webhook { $user = User::forceCreate([ From 4e40e232de8a8765b60eee4d1914f4ecc564b06c Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 1 Oct 2026 23:45:43 +0100 Subject: [PATCH 5/8] test: Compare stored webhook values independently of key order --- tests/Feature/IngressTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php index 9b1fb35..968bd16 100644 --- a/tests/Feature/IngressTest.php +++ b/tests/Feature/IngressTest.php @@ -31,7 +31,8 @@ public function an_incoming_delivery_keeps_the_bytes_it_arrived_as(): void $event = WebhookEvent::firstOrFail(); - $this->assertSame(['raw_body' => $raw, 'encoding' => 'utf-8'], $event->payload); + $this->assertSame($raw, $event->payload['raw_body']); + $this->assertSame('utf-8', $event->payload['encoding']); $this->assertSame($raw, $event->rawBody()); $this->assertSame( hash_hmac('sha256', $raw, 'a-secret'), From b8341b77b2612d7f40260a5bf840e7a3b1a370cb Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 1 Oct 2026 23:53:26 +0100 Subject: [PATCH 6/8] fix: Keep raw payload encoding under application control --- CHANGELOG.md | 2 +- README.md | 26 ++++++- src/Http/Controllers/WebhookController.php | 8 +- src/Models/WebhookEvent.php | 43 ----------- src/Support/RawPayload.php | 38 ++++++++++ tests/Feature/IngressTest.php | 85 +++------------------- tests/Unit/RawPayloadTest.php | 33 +++++++++ 7 files changed, 104 insertions(+), 131 deletions(-) create mode 100644 src/Support/RawPayload.php create mode 100644 tests/Unit/RawPayloadTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7532b7f..3d73d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [1.0.0] - 2026-06-16 - Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL. -- Incoming deliveries keep their original body in a JSON envelope. `rawBody()` restores the original bytes for signature checks; `decoded()` reads the body as data. +- Applications can use `RawPayload` to encode exact request bytes in JSON and decode them later. Incoming event payloads retain their existing parsed structure. - Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries. - Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures. - Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator. diff --git a/README.md b/README.md index d09fce8..a0da1f4 100644 --- a/README.md +++ b/README.md @@ -93,15 +93,33 @@ Incoming webhooks are sent to a unique URL containing a secure token. When a web 3. A `WebhookEvent` is recorded containing the payload and headers. 4. If `whilesmart/activities` is installed, an activity log is automatically created. -The JSON payload stores the original body as text: +Incoming events keep the parsed request fields in `payload`. The package does not reserve +keys or choose an application-specific payload structure. + +### Keeping raw bodies in an application + +Applications with a custom incoming handler can use `RawPayload` to preserve exact +request bytes inside a JSON value: + +```php +use Whilesmart\Webhooks\Support\RawPayload; + +$envelope = RawPayload::encode($request->getContent()); +$event->payload = $envelope; +$event->save(); + +$originalBody = RawPayload::decode($event->payload); +``` + +The application chooses where to store this envelope and how its readers consume it. +The default package ingress does not use it. Encoding produces: ```json {"raw_body": "{\"event\": \"opened\"}", "encoding": "utf-8"} ``` -Bodies that are not valid UTF-8 use base64 with `encoding: "base64"`. -Use `$event->rawBody()` to retrieve the original bytes for signature checks, -and `$event->decoded()` to read JSON objects, arrays or form data. +Non-UTF-8 bodies use base64. Decoding returns the original bytes, or `null` for an +unsupported envelope. It does not parse JSON or form data and does not verify signatures. ### Outbound Webhooks diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index 4158660..3bd8bd7 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -190,15 +190,9 @@ public function ingress(Request $request, string $token): JsonResponse try { $webhook->recordTrigger(); - $raw = $request->getContent(); - $isUtf8 = preg_match('//u', $raw) === 1; - WebhookEvent::create([ 'webhook_id' => $webhook->id, - 'payload' => [ - 'raw_body' => $isUtf8 ? $raw : base64_encode($raw), - 'encoding' => $isUtf8 ? 'utf-8' : 'base64', - ], + 'payload' => $request->all(), 'headers' => collect($request->headers->all()) ->except(['authorization', 'cookie']) ->toArray(), diff --git a/src/Models/WebhookEvent.php b/src/Models/WebhookEvent.php index cb55353..a674d9e 100644 --- a/src/Models/WebhookEvent.php +++ b/src/Models/WebhookEvent.php @@ -6,12 +6,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Whilesmart\Webhooks\Concerns\HasConfigurableKey; -/** - * @property int|string $webhook_id - * @property array|null $payload - * @property array|null $headers - * @property int|null $response_status - */ class WebhookEvent extends Model { use HasConfigurableKey; @@ -35,43 +29,6 @@ class WebhookEvent extends Model 'created_at' => 'datetime', ]; - public function rawBody(): ?string - { - $raw = $this->payload['raw_body'] ?? null; - - if (! is_string($raw)) { - return null; - } - - if (($this->payload['encoding'] ?? 'utf-8') === 'base64') { - $decoded = base64_decode($raw, true); - - return $decoded === false ? null : $decoded; - } - - return $raw; - } - - public function decoded(): ?array - { - $raw = $this->rawBody(); - - if (! is_string($raw) || trim($raw) === '') { - return null; - } - - $json = json_decode($raw, true); - - if (is_array($json)) { - return $json; - } - - // Some senders post a form rather than a document. - parse_str($raw, $form); - - return $form === [] ? null : $form; - } - protected static function boot() { parent::boot(); diff --git a/src/Support/RawPayload.php b/src/Support/RawPayload.php new file mode 100644 index 0000000..5941c8a --- /dev/null +++ b/src/Support/RawPayload.php @@ -0,0 +1,38 @@ + $isUtf8 ? $body : base64_encode($body), + 'encoding' => $isUtf8 ? 'utf-8' : 'base64', + ]; + } + + public static function decode(array $payload): ?string + { + $body = $payload['raw_body'] ?? null; + $encoding = $payload['encoding'] ?? null; + + if (! is_string($body)) { + return null; + } + + if ($encoding === 'utf-8') { + return $body; + } + + if ($encoding !== 'base64') { + return null; + } + + $decoded = base64_decode($body, true); + + return $decoded === false ? null : $decoded; + } +} diff --git a/tests/Feature/IngressTest.php b/tests/Feature/IngressTest.php index 968bd16..fd77ecd 100644 --- a/tests/Feature/IngressTest.php +++ b/tests/Feature/IngressTest.php @@ -11,95 +11,28 @@ class IngressTest extends TestCase { #[Test] - public function an_incoming_delivery_keeps_the_bytes_it_arrived_as(): void + public function incoming_json_keeps_application_fields_at_the_top_level(): void { $webhook = $this->incomingWebhook(); + $payload = ['action' => 'opened', 'raw_body' => 'application value', 'encoding' => 'application encoding']; - // Key order and spacing are part of what a signature covers, so the - // stored copy has to be the sender's bytes rather than a re-encoding. - $raw = '{"zeta":1, "alpha":{"nested":true}}'; - - $this->call( - 'POST', - "/webhooks/ingress/{$webhook->token}", - [], - [], - [], - ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], - $raw, - )->assertStatus(202); + $this->postJson("/webhooks/ingress/{$webhook->token}?source=customer", $payload)->assertStatus(202); $event = WebhookEvent::firstOrFail(); - $this->assertSame($raw, $event->payload['raw_body']); - $this->assertSame('utf-8', $event->payload['encoding']); - $this->assertSame($raw, $event->rawBody()); - $this->assertSame( - hash_hmac('sha256', $raw, 'a-secret'), - hash_hmac('sha256', $event->rawBody(), 'a-secret'), - ); - } - - #[Test] - public function the_payload_can_still_be_read_as_data(): void - { - $webhook = $this->incomingWebhook(); - - $this->call( - 'POST', - "/webhooks/ingress/{$webhook->token}", - [], - [], - [], - ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], - '{"action":"opened"}', - )->assertStatus(202); - - $this->assertSame('opened', WebhookEvent::firstOrFail()->decoded()['action']); + $this->assertEquals($payload + ['source' => 'customer'], $event->payload); + $this->assertEquals($event->payload, $event->toArray()['payload']); } #[Test] - public function a_form_encoded_delivery_reads_as_data_too(): void + public function incoming_forms_keep_application_fields_at_the_top_level(): void { $webhook = $this->incomingWebhook(); - $this->call( - 'POST', - "/webhooks/ingress/{$webhook->token}", - [], - [], - [], - ['CONTENT_TYPE' => 'application/x-www-form-urlencoded', 'HTTP_ACCEPT' => 'application/json'], - 'action=opened&number=7', - )->assertStatus(202); - - $this->assertSame('opened', WebhookEvent::firstOrFail()->decoded()['action']); - } - - #[Test] - public function plain_text_and_binary_bodies_survive_json_storage(): void - { - $webhook = $this->incomingWebhook(); - - foreach (["Text with \"quotes\", café and a newline\n", "\xFF\x00\x80", ''] as $raw) { - $this->call( - 'POST', - "/webhooks/ingress/{$webhook->token}", - [], - [], - [], - ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'], - $raw, - )->assertStatus(202); - - $event = WebhookEvent::orderByDesc('id')->firstOrFail(); + $this->post("/webhooks/ingress/{$webhook->token}", ['action' => 'opened', 'number' => '7']) + ->assertStatus(202); - $this->assertSame($raw, $event->rawBody()); - $this->assertSame( - hash_hmac('sha256', $raw, 'a-secret'), - hash_hmac('sha256', $event->rawBody(), 'a-secret'), - ); - } + $this->assertEquals(['action' => 'opened', 'number' => '7'], WebhookEvent::firstOrFail()->payload); } private function incomingWebhook(): Webhook diff --git a/tests/Unit/RawPayloadTest.php b/tests/Unit/RawPayloadTest.php new file mode 100644 index 0000000..631732d --- /dev/null +++ b/tests/Unit/RawPayloadTest.php @@ -0,0 +1,33 @@ +assertSame($body, $restored); + $this->assertSame(hash_hmac('sha256', $body, 'test-secret'), hash_hmac('sha256', $restored, 'test-secret')); + } + } + + #[Test] + public function ordinary_payloads_and_invalid_envelopes_are_not_decoded(): void + { + foreach ([['action' => 'opened'], ['raw_body' => 'text'], ['raw_body' => [], 'encoding' => 'utf-8'], + ['raw_body' => 'text', 'encoding' => 'unknown'], ['raw_body' => '!!!', 'encoding' => 'base64']] as $payload) { + $this->assertNull(RawPayload::decode($payload)); + } + } +} From cfa83586c2abf04b94c45337f4c7e00952c057b4 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 1 Oct 2026 23:59:19 +0100 Subject: [PATCH 7/8] docs: Remove unnecessary webhook comments --- config/webhooks.php | 3 --- .../migrations/2024_01_01_000012_create_webhooks_table.php | 4 ---- src/WebhooksServiceProvider.php | 4 ---- 3 files changed, 11 deletions(-) diff --git a/config/webhooks.php b/config/webhooks.php index 8b5ec8d..1d9df1d 100644 --- a/config/webhooks.php +++ b/config/webhooks.php @@ -15,9 +15,6 @@ 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], - // What guards the incoming address. A sender is a third party with only the - // token in the URL, so an authenticated stack here means no webhook ever - // arrives. Throttling belongs in this list. 'ingress_middleware' => [], /* diff --git a/database/migrations/2024_01_01_000012_create_webhooks_table.php b/database/migrations/2024_01_01_000012_create_webhooks_table.php index a23fe15..4a274c9 100644 --- a/database/migrations/2024_01_01_000012_create_webhooks_table.php +++ b/database/migrations/2024_01_01_000012_create_webhooks_table.php @@ -36,10 +36,6 @@ public function up(): void $table->timestamps(); $table->softDeletes(); - // Dispatch asks for one owner's active outgoing hooks on every event - // it sends. Indexing direction or is_active on their own is close to - // useless: each holds two values, so neither narrows anything and the - // planner ignores both. $table->index(['owner_type', 'owner_id', 'direction', 'is_active'], 'webhooks_owner_dispatch'); $table->index(['direction', 'is_active']); }); diff --git a/src/WebhooksServiceProvider.php b/src/WebhooksServiceProvider.php index f6499b7..96c7520 100644 --- a/src/WebhooksServiceProvider.php +++ b/src/WebhooksServiceProvider.php @@ -41,10 +41,6 @@ protected function registerRoutes(): void $this->loadRoutesFrom(__DIR__ . '/../routes/webhooks.php'); }); - // The address an incoming webhook is told to post to. It cannot sit - // behind the authenticated stack, because the sender is a third party - // holding nothing but the token in the URL. Every consumer was - // registering this by hand or the URL the model hands out answered 404. Route::group([ 'prefix' => config('webhooks.route_prefix', 'api'), 'middleware' => config('webhooks.ingress_middleware', []), From 575fbdc8dd5b76a550b23a3ad751ade7ef094dea Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 2 Oct 2026 00:02:09 +0100 Subject: [PATCH 8/8] docs: Explain incoming webhook middleware configuration --- README.md | 19 +++++++++++++++++++ docs/customization.md | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/README.md b/README.md index a0da1f4..8609123 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ return [ 'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true), 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], + 'ingress_middleware' => [], // Signed, retried outbound delivery. 'signing' => [ @@ -87,6 +88,24 @@ echo $webhook->url; // https://your-app.com/webhooks/ingress/{token} ### Webhook Ingress +`ingress_middleware` controls middleware on `POST /webhooks/ingress/{token}`. +It defaults to `[]` and does not inherit `route_middleware`, which protects +management routes. The receiving controller checks the URL token and requires an +active webhook; callers do not need a Sanctum login. + +To add throttling, set this in your published `config/webhooks.php`: + +```php +'ingress_middleware' => ['throttle:60,1'], +``` + +This applies Laravel's 60-request-per-minute throttle. Application middleware for +provider signature checks can also go in this list. The package does not verify +incoming signatures automatically. Application-wide middleware still applies. + +Both route groups use `route_prefix`; setting `register_routes` to `false` disables +both. See [route configuration](docs/customization.md#route-configuration). + Incoming webhooks are sent to a unique URL containing a secure token. When a webhook is triggered: 1. The token is validated. 2. The `trigger_count` and `last_triggered_at` fields are updated. diff --git a/docs/customization.md b/docs/customization.md index 201ba8d..f4a67d0 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -30,8 +30,32 @@ The package registers API routes automatically. You can customize the route pref 'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true), 'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''), 'route_middleware' => ['auth:sanctum'], +'ingress_middleware' => [], ``` +`route_middleware` applies to webhook management routes, including event listing. +`ingress_middleware` applies only to `POST /webhooks/ingress/{token}` and does not +inherit the management middleware. Its default is an empty list because webhook +senders authenticate with the URL token rather than a Sanctum login. The ingress +controller rejects unknown tokens and inactive webhooks. Global application +middleware still runs. + +Add middleware aliases or classes to the published configuration. For example, +Laravel's throttle middleware can limit incoming requests to 60 per minute: + +```php +'ingress_middleware' => ['throttle:60,1'], +``` + +Applications can also add middleware that checks a provider's signature against the +original request body. Incoming signature verification is not built into the package. +Adding `auth:sanctum` here requires senders to supply credentials accepted by the +application's Sanctum guard. + +`route_prefix` applies to both groups. For example, `api` makes the ingress path +`/api/webhooks/ingress/{token}`. Setting `register_routes` to `false` disables both +groups so the application can register its own routes. + ### Feature Flags You can enable or disable built-in support for Workspace scoping, Project scoping, and event tracking based on your needs: