Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions WEBHOOK_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions app/Jobs/SendWebhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
80 changes: 80 additions & 0 deletions tests/Feature/SendWebhookDeliveryIdHeaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace Tests\Feature;

use App\Jobs\SendWebhook;
use App\Models\Delivery;
use App\Models\Endpoint;
use App\Models\Event;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class SendWebhookDeliveryIdHeaderTest extends TestCase
{
use RefreshDatabase;

private function makeDelivery(User $user, array $attributes = []): Delivery
{
$event = Event::factory()->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');
});
}
}
Loading