Endpoint webhook robuste avec Laravel: signature HMAC, idempotence et traitement asynchrone
Créez un webhook Laravel sécurisé: signature HMAC, idempotence stricte et traitement asynchrone via jobs. Guide complet de la route au déploiement.
Sommaire
Endpoint webhook robuste avec Laravel: signature HMAC, idempotence et traitement asynchrone
Recevoir des webhooks en production exige une approche défensive: authentifier chaque requête, empêcher les doublons, répondre vite et traiter de façon fiable en arrière-plan. Ce tutoriel détaillé montre comment construire un endpoint robuste avec Laravel 10/11: vérification HMAC avec rotation de secret, idempotence stricte en base, file de jobs avec retries et backoff, observabilité, tests Pest, purge et préparation à la prod.
Objectif
L’objectif est de livrer un récepteur de webhooks prêt pour la production. Vous allez sécuriser l’endpoint via une signature HMAC avec fenêtre temporelle pour empêcher les replays, garantir l’idempotence par un schéma persistant et une contrainte d’unicité, et déléguer le travail réel à des jobs asynchrones exécutés par la queue. Le tout est câblé de la route à l’infrastructure de queue, avec logs dédiés, métriques et procédures de purge pour tenir sur la durée.
Objectif et prérequis
Concrètement, nous allons accepter des webhooks d’un fournisseur type “Stripe-like”, vérifier la signature cryptographique jointe à la requête, extraire un identifiant externe unique et s’assurer qu’un même événement n’est traité qu’une seule fois; si l’événement est nouveau, nous l’enregistrons en statut “pending” et envoyons un job ProcessWebhookEvent dans la queue, puis répondons immédiatement au fournisseur. Côté prérequis, il vous faut Laravel 10 ou 11, PHP 8.2+, et une queue configurée via Redis (recommandé) ou la base de données. Vous devez disposer d’un secret partagé fourni par l’émetteur des webhooks. Au terme du guide, vous aurez un middleware de signature, une migration + modèle pour les événements, un contrôleur d’ingestion, un job de traitement avec backoff et retries, un throttle dédié, des tests automatisés et une tâche planifiée pour purger l’historique.
Initialisation et configuration
Commencez par activer la queue. Dans votre .env, positionnez la connexion de queue selon votre infra. Avec Redis, vous aurez par exemple:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
Si vous préférez la base de données, positionnez QUEUE_CONNECTION=database, puis générez les tables nécessaires et migrez-les avec:
php artisan queue:table
php artisan queue:failed-table
php artisan migrate
Créez un canal de logs dédié pour les webhooks afin d’isoler et structurer les traces. Dans config/logging.php, ajoutez une entrée “webhooks” qui écrit en JSON pour faciliter l’ingestion par un SIEM ou des dashboards:
// config/logging.php (extrait)
'channels' => [
// ...
'webhooks' => [
'driver' => 'daily',
'path' => storage_path('logs/webhooks.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => 14,
'tap' => [App\Logging\CustomizeJsonFormatter::class],
],
],
Et créez un “tap” simple pour forcer le format JSON:
// app/Logging/CustomizeJsonFormatter.php
namespace App\Logging;
use Monolog\Formatter\JsonFormatter;
use Monolog\Logger;
class CustomizeJsonFormatter
{
public function __invoke($logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(new JsonFormatter());
}
}
}
Définissez les secrets du webhook dans l’environnement. Utilisez une variable active et préparez la rotation avec une variable “previous”:
WEBHOOK_SECRET=whsec_live_XXXXXXXXXXXXXXXX
WEBHOOK_SECRET_PREVIOUS=
WEBHOOK_TOLERANCE_SECONDS=300
Si vous déployez en production, installez Horizon pour monitorer la queue Redis avec:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Puis démarrez Horizon en local avec php artisan horizon, et configurez-le en prod via Supervisor (nous y reviendrons).
Enfin, centralisez quelques options spécifiques aux webhooks dans un fichier de configuration:
// config/webhooks.php
return [
'source' => 'vendor', // identifiant de la source émettrice
'secret' => env('WEBHOOK_SECRET'),
'previous_secret' => env('WEBHOOK_SECRET_PREVIOUS'),
'tolerance' => (int) env('WEBHOOK_TOLERANCE_SECONDS', 300),
'allowlist' => array_filter(explode(',', (string) env('WEBHOOK_IP_ALLOWLIST', ''))),
'max_payload_bytes' => (int) env('WEBHOOK_MAX_PAYLOAD_BYTES', 1024 * 1024), // 1 Mo par défaut
'handlers' => [
'user.created' => \App\Webhooks\Handlers\UserCreatedHandler::class,
'invoice.paid' => \App\Webhooks\Handlers\InvoicePaidHandler::class,
],
'purge_days_processed' => 30,
'purge_days_failed' => 90,
];
Schéma de persistance pour l’idempotence
Créez une migration pour la table “webhook_events” qui servira de journal fiable et de garde-fou contre les doublons. Elle contient l’identifiant externe fourni par le vendor, la source, la signature, le payload brut, des timestamps et un statut. L’unicité composite (source, external_id) bloque les réinsertions.
php artisan make:migration create_webhook_events_table
Puis implémentez la migration:
// database/migrations/xxxx_xx_xx_xxxxxx_create_webhook_events_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('source', 64)->index();
$table->string('external_id', 191);
$table->string('signature', 256)->nullable();
$table->json('payload');
$table->timestamp('received_at')->useCurrent()->index();
$table->enum('status', ['pending', 'processed', 'failed'])->default('pending')->index();
$table->unsignedInteger('attempts')->default(0);
$table->timestamp('processed_at')->nullable();
$table->text('error')->nullable();
$table->timestamps();
$table->unique(['source', 'external_id']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
Ajoutez le modèle Eloquent minimal avec les casts utiles:
// app/Models/WebhookEvent.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = [
'source',
'external_id',
'signature',
'payload',
'received_at',
'status',
'attempts',
'processed_at',
'error',
];
protected $casts = [
'payload' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
}
Avec cette base, l’idempotence est garantie par la base elle-même, en plus des précautions applicatives que nous allons voir.
Middleware de vérification HMAC
Le middleware lit l’en-tête de signature (ex: X-Signature), un timestamp (X-Timestamp) et le corps brut de la requête. Il calcule HMAC-SHA256 sur la base “body + '.' + timestamp”, compare en constant-time avec hash_equals et rejette si la fenêtre temporelle est dépassée. Il essaie d’abord le secret actif puis le secret précédent, et journalise l’usage d’un secret ancien pour faciliter la rotation.
php artisan make:middleware VerifyWebhookSignature
// app/Http/Middleware/VerifyWebhookSignature.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
// Générer un correlation_id pour tracer le flux
$correlationId = (string) Str::uuid();
Log::withContext(['correlation_id' => $correlationId]);
// Forcer JSON et méthode POST
if (strtolower($request->method()) !== 'post') {
return response('Method Not Allowed', 405);
}
if (!str_contains(strtolower((string) $request->header('content-type')), 'application/json')) {
return response('Unsupported Media Type', 415);
}
// Limite de taille
$max = (int) config('webhooks.max_payload_bytes', 1024 * 1024);
$raw = $request->getContent();
if (strlen($raw) > $max) {
Log::channel('webhooks')->warning('Payload too large', ['size' => strlen($raw)]);
return response('Payload Too Large', 413);
}
$signatureHeader = (string) $request->header('X-Signature', '');
$timestampHeader = (string) $request->header('X-Timestamp', '');
if ($signatureHeader === '' || $timestampHeader === '') {
return response('Unauthorized', 401);
}
// Validation fenêtre temporelle
$tolerance = (int) config('webhooks.tolerance', 300);
$ts = (int) $timestampHeader;
$now = time();
if ($ts < ($now - $tolerance) || $ts > ($now + $tolerance)) {
Log::channel('webhooks')->warning('Timestamp outside tolerance', ['ts' => $ts, 'now' => $now]);
return response('Unauthorized', 401);
}
// Calcul HMAC: hexadécimal en SHA-256 sur "body.timestamp"
$message = $raw . '.' . $timestampHeader;
$secret = (string) config('webhooks.secret');
$previous = (string) config('webhooks.previous_secret');
$computed = $secret !== '' ? hash_hmac('sha256', $message, $secret) : '';
$valid = $computed !== '' && hash_equals($computed, strtolower($signatureHeader));
$usedPrevious = false;
if (!$valid && $previous !== '') {
$computedPrev = hash_hmac('sha256', $message, $previous);
$valid = hash_equals($computedPrev, strtolower($signatureHeader));
$usedPrevious = $valid;
}
if (!$valid) {
Log::channel('webhooks')->warning('Invalid signature', ['cid' => $correlationId]);
return response('Unauthorized', 401);
}
if ($usedPrevious) {
Log::channel('webhooks')->notice('Valid signature with previous secret', ['cid' => $correlationId]);
}
// Passe la main avec le correlation_id disponible
$request->attributes->set('correlation_id', $correlationId);
return $next($request);
}
}
Enregistrez le middleware dans app/Http/Kernel.php, dans la section routeMiddleware:
protected $routeMiddleware = [
// ...
'verify.webhook.signature' => \App\Http\Middleware\VerifyWebhookSignature::class,
];
Route et contrôleur: acquitter vite, travailler en arrière-plan
Exposez une route POST /webhooks/vendor protégée par le middleware de signature et par un throttle dédié. Déclarez un rate limiter “webhooks” pour contrôler les bursts sans bloquer la prod: 60 requêtes par minute par IP par défaut.
// app/Providers/RouteServiceProvider.php (méthode configureRateLimiting)
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting(): void
{
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(60)->by('webhooks:'.$request->ip())->response(function () {
return response('Too Many Requests', 429);
});
});
}
Ajoutez ensuite la route:
// routes/api.php (ou routes/web.php si vous n’utilisez pas de cookie/CSRF)
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/vendor', [WebhookController::class, 'store'])
->middleware(['verify.webhook.signature', 'throttle:webhooks'])
->name('webhooks.vendor');
Désactivez CSRF pour cette route si vous utilisez routes/web.php:
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhooks/vendor',
];
Implémentez le contrôleur. Il lit le JSON, valide les champs minimaux, enregistre l’événement en “pending” via firstOrCreate (idempotence), planifie le job et répond 202. Si l’événement existe déjà, il répond 200 sans re-traiter.
php artisan make:controller WebhookController
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class WebhookController extends Controller
{
public function store(Request $request)
{
$payload = $request->json()->all();
$correlationId = $request->attributes->get('correlation_id');
try {
$data = validator($payload, [
'id' => ['required', 'string', 'max:191'],
'type' => ['required', 'string', 'max:191'],
])->validate();
} catch (ValidationException $e) {
Log::channel('webhooks')->warning('Invalid payload schema', [
'correlation_id' => $correlationId,
'errors' => $e->errors(),
]);
return response('Bad Request', 400);
}
$source = config('webhooks.source', 'vendor');
$externalId = $data['id'];
$signature = (string) $request->header('X-Signature');
try {
$event = WebhookEvent::firstOrCreate(
['source' => $source, 'external_id' => $externalId],
[
'signature' => $signature,
'payload' => $payload,
'received_at' => now(),
'status' => 'pending',
'attempts' => 0,
]
);
} catch (QueryException $e) {
// Barrière d’unicité en concurrence
Log::channel('webhooks')->info('Duplicate webhook (unique constraint)', [
'correlation_id' => $correlationId,
'source' => $source,
'external_id' => $externalId,
]);
return response('OK', 200);
}
if ($event->wasRecentlyCreated === false) {
Log::channel('webhooks')->info('Duplicate webhook (already exists)', [
'correlation_id' => $correlationId,
'source' => $source,
'external_id' => $externalId,
]);
return response('OK', 200);
}
ProcessWebhookEvent::dispatch($event->id, $correlationId);
Log::channel('webhooks')->info('Webhook queued', [
'correlation_id' => $correlationId,
'source' => $source,
'external_id' => $externalId,
]);
return response(null, Response::HTTP_ACCEPTED);
}
}
Avec cette approche, l’API du fournisseur reçoit un acquittement immédiat (202) et votre traitement est entièrement confié à la queue.
Job de traitement: retries, backoff, verrouillage
Le job récupère l’événement, prend un verrou par couple (source:external_id) pour empêcher toute concurrence, applique un backoff exponentiel en cas d’échec et abandonne après un TTL raisonnable. Au succès, l’événement passe en “processed”; sinon, en “failed” lorsque les retries sont épuisés.
php artisan make:job ProcessWebhookEvent
// app/Jobs/ProcessWebhookEvent.php
namespace App\Jobs;
use App\Events\WebhookFailed;
use App\Events\WebhookProcessed;
use App\Models\WebhookEvent;
use App\Webhooks\HandlerRegistry;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ProcessWebhookEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $eventId;
public ?string $correlationId;
public function __construct(int $eventId, ?string $correlationId = null)
{
$this->eventId = $eventId;
$this->correlationId = $correlationId;
}
public function backoff(): array
{
return [10, 30, 60, 120];
}
public function retryUntil(): \DateTime
{
return now()->addHour()->toDateTime();
}
public function middleware(): array
{
// Empêche le chevauchement de ce même job sur ce même event
return [
(new WithoutOverlapping('webhook-job-'.$this->eventId))->expireAfter(120),
];
}
public function handle(HandlerRegistry $registry): void
{
Log::withContext(['correlation_id' => $this->correlationId]);
$event = WebhookEvent::find($this->eventId);
if (!$event) {
Log::channel('webhooks')->warning('Event not found', ['event_id' => $this->eventId]);
return;
}
$lockKey = sprintf('webhook:%s:%s', $event->source, $event->external_id);
$lock = Cache::lock($lockKey, 120);
if (!$lock->get()) {
// Un autre worker traite déjà ce couple
$this->release(10);
return;
}
$started = microtime(true);
try {
$type = data_get($event->payload, 'type');
$handler = $registry->resolve($type);
$handler->handle($event);
$event->status = 'processed';
$event->processed_at = now();
$event->error = null;
$event->save();
$durationMs = (int) ((microtime(true) - $started) * 1000);
Log::channel('webhooks')->info('Webhook processed', [
'event_id' => $event->id,
'source' => $event->source,
'external_id' => $event->external_id,
'type' => $type,
'duration_ms' => $durationMs,
'attempts' => $event->attempts,
]);
event(new WebhookProcessed($event));
} catch (\Throwable $e) {
$event->attempts = ($event->attempts ?? 0) + 1;
$event->error = $e->getMessage();
$event->save();
Log::channel('webhooks')->error('Webhook processing failed', [
'event_id' => $event->id,
'error' => $e->getMessage(),
'trace' => substr($e->getTraceAsString(), 0, 4000),
'attempts' => $event->attempts,
]);
if ($this->attempts() >= count($this->backoff())) {
$event->status = 'failed';
$event->save();
event(new WebhookFailed($event));
}
throw $e;
} finally {
optional($lock)->release();
}
}
}
Ce job utilise WithoutOverlapping côté queue et un verrou Redis côté application pour s’assurer qu’un même événement ne sera jamais traité en parallèle, même si des jobs concurrents sont planifiés.
Gestion des handlers par type d’événement
Routez les événements par event_type vers des classes dédiées. Utilisez un simple registre injectable pour découpler la résolution et faciliter les tests.
// app/Webhooks/Contracts/WebhookHandler.php
namespace App\Webhooks\Contracts;
use App\Models\WebhookEvent;
interface WebhookHandler
{
public function handle(WebhookEvent $event): void;
}
// app/Webhooks/HandlerRegistry.php
namespace App\Webhooks;
use App\Webhooks\Contracts\WebhookHandler;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
class HandlerRegistry
{
public function __construct(private Container $container) {}
public function resolve(?string $type): WebhookHandler
{
$map = config('webhooks.handlers', []);
$class = $type && isset($map[$type]) ? $map[$type] : null;
if (!$class) {
throw new InvalidArgumentException("No handler registered for type [$type]");
}
return $this->container->make($class);
}
}
Exemple de handlers idempotents. Ils doivent faire des upsert, update-or-insert ou des checks de version pour pouvoir être rejoués sans effets de bord.
// app/Webhooks/Handlers/UserCreatedHandler.php
namespace App\Webhooks\Handlers;
use App\Models\User;
use App\Models\WebhookEvent;
use App\Webhooks\Contracts\WebhookHandler;
use Illuminate\Support\Arr;
class UserCreatedHandler implements WebhookHandler
{
public function handle(WebhookEvent $event): void
{
$data = $event->payload['data'] ?? [];
$extId = (string) ($data['user_external_id'] ?? $event->external_id);
$email = (string) ($data['email'] ?? '');
$name = (string) ($data['name'] ?? '');
// Idempotent: upsert par identifiant externe
User::updateOrCreate(
['external_id' => $extId],
['email' => $email, 'name' => $name]
);
}
}
// app/Webhooks/Handlers/InvoicePaidHandler.php
namespace App\Webhooks\Handlers;
use App\Models\Invoice;
use App\Models\WebhookEvent;
use App\Webhooks\Contracts\WebhookHandler;
use Illuminate\Support\Facades\DB;
class InvoicePaidHandler implements WebhookHandler
{
public function handle(WebhookEvent $event): void
{
$invoiceId = data_get($event->payload, 'data.invoice_id');
$amount = (int) data_get($event->payload, 'data.amount', 0);
$currency = (string) data_get($event->payload, 'data.currency', 'EUR');
DB::transaction(function () use ($invoiceId, $amount, $currency) {
$invoice = Invoice::lockForUpdate()->where('external_id', $invoiceId)->first();
if (!$invoice) {
// Option: créer une coquille si votre modèle métier le supporte
return;
}
// Idempotent: ne pas re-appliquer si déjà paid
if ($invoice->status !== 'paid') {
$invoice->status = 'paid';
$invoice->paid_amount = $amount;
$invoice->currency = $currency;
$invoice->paid_at = now();
$invoice->save();
}
});
}
}
Sécurité et robustesse complémentaires
Ajoutez un throttle dédié par IP pour limiter les bursts et journaliser les dépassements. Le RateLimiter configuré plus haut s’en charge, et Laravel enregistrera un 429 lorsque le quota est dépassé. Limitez aussi la taille des payloads dans le middleware de signature, puis validez le schéma JSON minimal dans le contrôleur pour filtrer les envois malformés. Désactivez CSRF sur la route et refusez toute méthode autre que POST, en vérifiant strictement Content-Type application/json. Si le fournisseur publie une plage IP fiable, implémentez une allowlist simple qui court-circuite lorsque l’IP ne correspond pas:
// app/Http/Middleware/AllowlistWebhookIp.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class AllowlistWebhookIp
{
public function handle(Request $request, Closure $next)
{
$allow = config('webhooks.allowlist', []);
if (!empty($allow) && !in_array($request->ip(), $allow, true)) {
return response('Forbidden', 403);
}
return $next($request);
}
}
Puis montez-le sur la route si vous avez une allowlist. Si ce n’est pas le cas, la signature HMAC + tolérance temporelle fournissent une garantie robuste contre l’usurpation et le replay.
Observabilité et traçage
Écrivez des logs structurés dans le canal “webhooks” avec les champs event_id, source, external_id, type, status, duration_ms et attempts. Le middleware génère un correlation_id propagé dans les logs et transféré au job, ce qui permet de reconstruire facilement le parcours d’un événement sur plusieurs processus. Exposez des événements Laravel pour brancher des métriques: par exemple, écoutez WebhookReceived, WebhookProcessed et WebhookFailed pour incrémenter des compteurs Prometheus ou StatsD.
// app/Events/WebhookProcessed.php
namespace App\Events;
use App\Models\WebhookEvent;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WebhookProcessed
{
use Dispatchable, SerializesModels;
public function __construct(public WebhookEvent $event) {}
}
// app/Events/WebhookFailed.php
namespace App\Events;
use App\Models\WebhookEvent;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WebhookFailed
{
use Dispatchable, SerializesModels;
public function __construct(public WebhookEvent $event) {}
}
Pour enrichir les logs, utilisez Log::withContext dans le middleware et dans les jobs, puis ajoutez les champs communs dans chaque entrée Log::channel('webhooks')->info(...). Vous pouvez ensuite agréger des métriques telles que le total d’événements reçus, validés, rejetés, la latence moyenne de traitement ou le taux d’erreur par type.
Tests automatisés (Pest)
Couvrez les chemins critiques avec Pest: signature, fenêtre temporelle, idempotence, dispatch des jobs, retries et throttle. Créez d’abord un helper pour signer un payload de test avec votre secret:
// tests/Helpers/WebhookSign.php
namespace Tests\Helpers;
class WebhookSign
{
public static function headers(array $payload, ?int $ts = null, ?string $secret = null): array
{
$secret ??= config('webhooks.secret');
$ts ??= time();
$raw = json_encode($payload, JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $raw.'.'.$ts, $secret);
return [
'X-Timestamp' => (string) $ts,
'X-Signature' => $sig,
'Content-Type' => 'application/json',
];
}
}
Testez la signature, y compris l’absence d’en-têtes, une signature invalide et un timestamp hors fenêtre:
// tests/Feature/WebhookSignatureTest.php
use Tests\Helpers\WebhookSign;
it('rejects missing signature', function () {
$payload = ['id' => 'evt_1', 'type' => 'user.created', 'data' => []];
$this->postJson('/api/webhooks/vendor', $payload)->assertStatus(401);
});
it('rejects invalid signature', function () {
$payload = ['id' => 'evt_2', 'type' => 'user.created', 'data' => []];
$headers = WebhookSign::headers($payload);
$headers['X-Signature'] = 'deadbeef';
$this->postJson('/api/webhooks/vendor', $payload, $headers)->assertStatus(401);
});
it('rejects timestamp outside tolerance', function () {
$payload = ['id' => 'evt_3', 'type' => 'user.created', 'data' => []];
$ts = time() - (config('webhooks.tolerance') + 10);
$headers = WebhookSign::headers($payload, $ts);
$this->postJson('/api/webhooks/vendor', $payload, $headers)->assertStatus(401);
});
Vérifiez l’idempotence: deux envois du même external_id n’insèrent qu’un enregistrement et un seul traitement:
// tests/Feature/WebhookIdempotenceTest.php
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Support\Facades\Bus;
use Tests\Helpers\WebhookSign;
it('is idempotent on duplicate external_id', function () {
Bus::fake();
$payload = ['id' => 'evt_dup', 'type' => 'user.created', 'data' => ['email' => 'a@example.com']];
$headers = WebhookSign::headers($payload);
$this->postJson('/api/webhooks/vendor', $payload, $headers)->assertStatus(202);
$this->postJson('/api/webhooks/vendor', $payload, $headers)->assertStatus(200);
expect(WebhookEvent::where('external_id', 'evt_dup')->count())->toBe(1);
Bus::assertDispatchedTimes(ProcessWebhookEvent::class, 1);
});
Faites un test d’exécution du job et du statut processed:
// tests/Feature/WebhookJobTest.php
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
it('processes the job successfully', function () {
$event = WebhookEvent::create([
'source' => config('webhooks.source'),
'external_id' => 'evt_job_1',
'payload' => ['id' => 'evt_job_1', 'type' => 'user.created', 'data' => ['email' => 'x@y.z']],
'status' => 'pending',
'attempts' => 0,
'received_at' => now(),
]);
dispatch_sync(new ProcessWebhookEvent($event->id, 'cid-test'));
$event->refresh();
expect($event->status)->toBe('processed');
expect($event->processed_at)->not->toBeNull();
});
Simulez un échec pour valider les retries et le backoff. Vous pouvez créer un handler de test qui lance une exception tant qu’un flag n’est pas positionné, ou moquer le registry pour lancer une exception:
// tests/Feature/WebhookRetriesTest.php
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use App\Webhooks\HandlerRegistry;
it('increments attempts on failure', function () {
$event = WebhookEvent::create([
'source' => config('webhooks.source'),
'external_id' => 'evt_fail',
'payload' => ['id' => 'evt_fail', 'type' => 'user.created', 'data' => []],
'status' => 'pending',
'attempts' => 0,
'received_at' => now(),
]);
$this->mock(HandlerRegistry::class, function ($mock) {
$mock->shouldReceive('resolve')->andThrow(new RuntimeException('boom'));
});
try {
dispatch_sync(new ProcessWebhookEvent($event->id));
} catch (\Throwable $e) {
// ignore
}
$event->refresh();
expect($event->attempts)->toBe(1);
});
Enfin, validez le throttle en dépassant le quota:
// tests/Feature/WebhookThrottleTest.php
use Tests\Helpers\WebhookSign;
it('throttles when exceeding quota', function () {
$payload = ['id' => 'evt_t', 'type' => 'user.created', 'data' => []];
$headers = WebhookSign::headers($payload);
$responses = [];
for ($i = 0; $i < 70; $i++) {
$responses[] = $this->postJson('/api/webhooks/vendor', $payload, $headers);
}
$has429 = collect($responses)->contains(fn ($r) => $r->getStatusCode() === 429);
expect($has429)->toBeTrue();
});
Purge, supervision et mise en production
Planifiez une purge régulière des événements traités pour contenir la taille de la table. Une commande simple supprime (ou archive) les lignes selon l’âge et le statut, en conservant plus longtemps les échecs pour post-mortem.
php artisan make:command PurgeWebhookEvents
// app/Console/Commands/PurgeWebhookEvents.php
namespace App\Console\Commands;
use App\Models\WebhookEvent;
use Illuminate\Console\Command;
class PurgeWebhookEvents extends Command
{
protected $signature = 'webhooks:purge';
protected $description = 'Purge old webhook_events';
public function handle(): int
{
$daysProcessed = (int) config('webhooks.purge_days_processed', 30);
$daysFailed = (int) config('webhooks.purge_days_failed', 90);
$deletedProcessed = WebhookEvent::where('status', 'processed')
->where('received_at', '<', now()->subDays($daysProcessed))
->delete();
$deletedFailed = WebhookEvent::where('status', 'failed')
->where('received_at', '<', now()->subDays($daysFailed))
->delete();
$this->info("Deleted processed: {$deletedProcessed}, failed: {$deletedFailed}");
return self::SUCCESS;
}
}
Ajoutez-la au scheduler:
// app/Console/Kernel.php
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$schedule->command('webhooks:purge')->dailyAt('03:30');
}
Pour la supervision en production, démarrez des workers robustes via Supervisor ou via Horizon si vous utilisez Redis. Une configuration Supervisor typique lance des workers queue:work avec des bornes de mémoire, durée maximale et nombre de jobs afin d’éviter les fuites:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --queue=default --sleep=1 --tries=3 --max-time=3600 --memory=512
autostart=true
autorestart=true
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
stopwaitsecs=3600
Préparez la rotation de secrets en renseignant WEBHOOK_SECRET_PREVIOUS avec l’ancien secret, puis en le supprimant après la transition. Le middleware journalise l’usage du secret précédent; créez une alerte sur ce pattern pour accélérer la dépréciation. En local, exposez votre endpoint via un tunnel et générez des signatures de test. Par exemple, avec ngrok et Node.js:
ngrok http http://127.0.0.1:8000
// tools/sign.js
const crypto = require('crypto');
const secret = process.env.WEBHOOK_SECRET || 'whsec_dev';
const payload = JSON.stringify({
id: 'evt_local_1',
type: 'user.created',
data: { email: 'dev@example.test' },
});
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac('sha256', secret).update(`${payload}.${ts}`).digest('hex');
console.log('X-Timestamp:', ts);
console.log('X-Signature:', sig);
console.log('Body:', payload);
Vous pouvez aussi signer depuis bash en utilisant openssl:
payload='{"id":"evt_local_2","type":"user.created","data":{"email":"bash@example.test"}}'
ts=$(date +%s)
sig=$(printf "%s.%s" "$payload" "$ts" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary | xxd -p -c 256)
echo "X-Timestamp: $ts"
echo "X-Signature: $sig"
Enfin, constituez un petit playbook de debug: activez le niveau DEBUG sur le canal webhooks dans .env, regardez les attempts et les erreurs dans la table webhook_events, inspectez la file failed_jobs si vous utilisez la queue database, et surveillez les verrous Redis en cas de contentions inhabituelles.
Checklist
Avant la mise en production, relisez le code et les messages de log pour vérifier la clarté et la consistance. Exécutez les tests automatisés Pest et complétez-les avec des scénarios métier représentatifs. Vérifiez manuellement tous les snippets de commandes: migrations, scheduler, Supervisor/Horizon et outils de signature. Déployez sur un environnement de staging, faites tirer de vrais webhooks par le fournisseur, observez les métriques et les logs, puis publiez lorsque la stabilité est démontrée.
Ressources
Consultez la documentation Laravel sur la queue, les jobs et les rate limiters: https://laravel.com/docs/11.x/queues et https://laravel.com/docs/11.x/routing#rate-limiting. Référez-vous à la doc Horizon si vous optez pour Redis et souhaitez un monitoring natif: https://laravel.com/docs/11.x/horizon. Les signatures HMAC sont décrites par le RFC 2104, et la documentation Stripe sur la vérification des signatures de webhook propose un excellent cadre conceptuel: https://stripe.com/docs/webhooks/signatures. Pour les tests, Pest offre une expérience fluide: https://pestphp.com. Enfin, Monolog et la configuration des formatters JSON sont documentés ici: https://github.com/Seldaek/monolog.
Conclusion
Vous disposez désormais d’un endpoint webhook conçu pour la production: authentifié via HMAC avec rotation de secret, résilient grâce à l’idempotence et au traitement asynchrone, observable par des logs structurés et des événements, et maintenable grâce à des tests et des procédures de purge. Cette base vous permet d’ajouter sereinement de nouveaux types d’événements et d’enrichir les handlers sans compromettre la robustesse globale. Lorsque vous intégrerez un nouveau fournisseur, il vous suffira d’ajuster le middleware si le schéma de signature diffère, d’ajouter les handlers nécessaires et de déployer avec les mêmes garde-fous.