From 8511dd2b06f91775a58b0d70ba4385a08a446ee2 Mon Sep 17 00:00:00 2001 From: Morris Jencen Chavez Date: Fri, 11 Sep 2026 19:21:42 +0000 Subject: [PATCH] fix: add delivery id and attempt headers to outbound webhooks Webhook deliveries carried no per-delivery identifier, so a receiving endpoint had no way to recognize that two HTTP calls (a manual retry, a network-level retry, or a scheduled retry) corresponded to the same logical delivery, leaving them unable to dedupe and risking duplicate side effects like double-charging or double-provisioning. Add an X-Webhook-Delivery-Id header (the delivery's stable ID) and an X-Webhook-Attempt header (the current attempt number) to every outbound webhook request in SendWebhook, and document both in WEBHOOK_README.md's Webhook Security section alongside the existing X-Webhook-Secret/X-Webhook-Event headers. Fixes #43 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VhUzR11zCCYyxBu4ixbpjF --- WEBHOOK_README.md | 2 + app/Jobs/SendWebhook.php | 2 + .../SendWebhookDeliveryIdHeaderTest.php | 80 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/Feature/SendWebhookDeliveryIdHeaderTest.php diff --git a/WEBHOOK_README.md b/WEBHOOK_README.md index e916b8f..f0bf2c7 100644 --- a/WEBHOOK_README.md +++ b/WEBHOOK_README.md @@ -301,6 +301,8 @@ Webhooks include security headers for verification: - `X-Webhook-Secret`: HMAC-SHA256 signature of the payload - `X-Webhook-Event`: The event name that triggered the webhook +- `X-Webhook-Delivery-Id`: The delivery's unique ID. Stable across retries of the same delivery — use it to dedupe if the same delivery is received more than once (e.g. due to a network-level retry or a manual retry). +- `X-Webhook-Attempt`: The attempt number for this delivery (`1` for the first attempt, incrementing on each retry) - `User-Agent`: Webhook-Management-Platform/1.0 Example verification (Node.js): diff --git a/app/Jobs/SendWebhook.php b/app/Jobs/SendWebhook.php index 457c150..3c172b3 100644 --- a/app/Jobs/SendWebhook.php +++ b/app/Jobs/SendWebhook.php @@ -149,6 +149,8 @@ public function handle(): void 'Content-Type' => 'application/json', 'X-Webhook-Secret' => hash_hmac('sha256', json_encode($payload), $endpoint->secret_key), 'X-Webhook-Event' => $event->name, + 'X-Webhook-Delivery-Id' => (string) $this->delivery->id, + 'X-Webhook-Attempt' => (string) $this->delivery->attempt_count, 'User-Agent' => 'Webhook-Management-Platform/1.0', ]) ->post($endpoint->url, $payload); diff --git a/tests/Feature/SendWebhookDeliveryIdHeaderTest.php b/tests/Feature/SendWebhookDeliveryIdHeaderTest.php new file mode 100644 index 0000000..a306e33 --- /dev/null +++ b/tests/Feature/SendWebhookDeliveryIdHeaderTest.php @@ -0,0 +1,80 @@ +for($user)->create(); + $endpoint = Endpoint::factory()->for($user)->create(['url' => 'http://8.8.8.8/webhook', 'is_active' => true]); + + return Delivery::factory()->create(array_merge([ + 'event_id' => $event->id, + 'endpoint_id' => $endpoint->id, + 'status' => 'pending', + 'attempt_count' => 0, + 'next_retry_at' => null, + ], $attributes)); + } + + public function test_delivery_id_and_attempt_headers_are_sent_with_the_request(): void + { + Http::fake([ + '*' => Http::response('ok', 200), + ]); + + $delivery = $this->makeDelivery(User::factory()->withPersonalTeam()->create()); + + $job = (new SendWebhook($delivery))->withFakeQueueInteractions(); + $job->handle(); + + $delivery->refresh(); + + Http::assertSent(function ($request) use ($delivery) { + return $request->hasHeader('X-Webhook-Delivery-Id', (string) $delivery->id) + && $request->hasHeader('X-Webhook-Attempt', (string) $delivery->attempt_count); + }); + } + + public function test_delivery_id_stays_stable_while_attempt_number_increments_across_retries(): void + { + Http::fake([ + '*' => Http::response('error', 500), + ]); + + $delivery = $this->makeDelivery(User::factory()->withPersonalTeam()->create()); + $deliveryId = (string) $delivery->id; + + // First attempt. + $job = (new SendWebhook($delivery))->withFakeQueueInteractions(); + $job->handle(); + + Http::assertSent(function ($request) use ($deliveryId) { + return $request->hasHeader('X-Webhook-Delivery-Id', $deliveryId) + && $request->hasHeader('X-Webhook-Attempt', '1'); + }); + + // Simulate the scheduled retry re-dispatch. + $delivery->refresh(); + $delivery->update(['next_retry_at' => null]); + $job = (new SendWebhook($delivery))->withFakeQueueInteractions(); + $job->handle(); + + Http::assertSent(function ($request) use ($deliveryId) { + return $request->hasHeader('X-Webhook-Delivery-Id', $deliveryId) + && $request->hasHeader('X-Webhook-Attempt', '2'); + }); + } +}