Recevoir des webhooks de façon robuste avec Laravel: HMAC, idempotence, retries
Webhooks Laravel sécurisés: HMAC, idempotence, déduplication, queue et backoff. Traitez chaque événement sans doublon, rapidement et en toute sécurité.
Sommaire
Recevoir des webhooks de façon robuste avec Laravel: HMAC, idempotence, retries
Recevoir des webhooks en production exige de la rigueur: authentifier chaque message, éviter les doublons, répondre vite, et traiter en arrière-plan avec des retries bien configurés. Ce tutoriel montre pas à pas comment construire, avec Laravel 10/11 et PHP 8.2+, un point d’entrée de webhooks fiable, idempotent et observable.
Objectif
L’objectif est de livrer un endpoint POST /webhooks/{source} qui vérifie la signature HMAC, enregistre l’appel en base, met le traitement en file, applique une idempotence forte, et gère les retries avec backoff. Ce pipeline permet de traiter des évènements externes (ex: Stripe, GitHub, SendGrid) sans doublons, rapidement, et en toute sécurité, tout en renvoyant une réponse HTTP 204 en moins de 200 ms afin de respecter les contraintes des providers.
Plan et prérequis
Nous allons produire un endpoint POST /webhooks/{source} protégé par une vérification de signature HMAC, sauvegarder chaque réception en base, dispatcher un job asynchrone, appliquer une idempotence stricte au niveau du job, et configurer des retries exponentiels. En pratique, le pipeline consiste à créer une migration et un modèle WebhookCall, un fichier de configuration par source, un middleware de vérification de signature, un contrôleur d’entrée qui répond rapidement, un job ProcessWebhookCall qui encapsule le traitement métier, un registre de handlers par type d’évènement, et une suite de tests robustes.
Les prérequis sont un projet Laravel 10/11 tournant sur PHP 8.2+, ainsi qu’une base SQL (MySQL ou PostgreSQL). Redis et Horizon sont fortement recommandés pour la file, mais vous pouvez démarrer avec la file database. Si vous utilisez Redis, veillez à configurer QUEUE_CONNECTION=redis et à faire tourner vos workers.
Migration et modèle WebhookCall
Commencez par générer la migration, puis implémentez un schéma qui stocke la source (ex: stripe), l’identifiant de l’évènement externe s’il existe, la signature, le payload, l’IP d’origine, des timestamps de réception et de traitement, un statut, et un éventuel message d’erreur. Ajoutez des index pour l’idempotence et les requêtes fréquentes.
Commande à exécuter:
php artisan make:migration create_webhook_calls_table
Migration complète:
<?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_calls', function (Blueprint $table) {
$table->id();
$table->string('source');
$table->string('external_id')->nullable();
$table->string('signature')->nullable();
$table->json('payload');
$table->string('ip', 45)->nullable();
$table->timestamp('received_at');
$table->timestamp('processed_at')->nullable();
$table->enum('status', ['pending', 'processed', 'failed'])->default('pending')->index();
$table->text('error')->nullable();
$table->timestamps();
// Idempotence: selon la source, on dédupliquera par external_id OU par signature.
$table->unique(['source', 'external_id']);
$table->unique(['source', 'signature']);
$table->index('received_at');
});
}
public function down(): void
{
Schema::dropIfExists('webhook_calls');
}
};
Créez ensuite le modèle Eloquent pour manipuler la table et castez les colonnes JSON et dates.
Commande à exécuter:
php artisan make:model WebhookCall
Modèle:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookCall extends Model
{
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
}
Configuration des sources et secrets
Centralisez la configuration des sources (secrets, en-têtes, algorithmes, chemins d’ID et de type) dans config/webhooks.php. Cela vous permettra d’ajouter une nouvelle source (GitHub, SendGrid, Slack…) sans toucher au code du middleware ou du contrôleur.
Fichier config/webhooks.php:
<?php
return [
'sources' => [
'stripe' => [
'secret' => env('WEBHOOK_STRIPE_SECRET'),
'sig_header' => 'Stripe-Signature',
'ts_header' => 'Stripe-Timestamp',
'algo' => 'sha256',
'clock_skew' => 300,
'id_path' => 'id',
'type_path' => 'type',
],
// 'github' => [...],
// 'sendgrid' => [...],
],
// Registre des handlers par type d’évènement (section détaillée plus bas)
'handlers' => [
'stripe' => [
'invoice.paid' => App\Webhook\Stripe\InvoicePaid::class,
'customer.subscription.deleted' => App\Webhook\Stripe\SubscriptionCancelled::class,
],
],
];
Renseignez le secret dans .env pour chaque provider. Par exemple, pour Stripe:
WEBHOOK_STRIPE_SECRET=whsec_votre_cle_fourni_par_stripe
L’avantage est immédiat: pour ajouter un provider, vous déclarez son secret, ses en-têtes, l’algorithme HMAC, les chemins pour extraire l’ID et le type, et c’est tout. Le reste de l’infrastructure reste commun et réutilisable.
Middleware de vérification HMAC
Un middleware dédié simplifie la vérification de la signature et refuse tôt toute requête invalide. Il récupère le raw body, le timestamp et la signature depuis les en-têtes configurés, contrôle la dérive d’horloge, calcule l’HMAC attendu et compare en temps constant.
Commande à exécuter:
php artisan make:middleware VerifyWebhookSignature
Middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$source = $request->route('source');
$cfg = config("webhooks.sources.$source");
if (!$cfg) {
abort(404, 'Source inconnue');
}
$raw = $request->getContent();
$sigHeader = $cfg['sig_header'] ?? null;
$tsHeader = $cfg['ts_header'] ?? null;
$sig = $sigHeader ? $request->header($sigHeader) : null;
$ts = $tsHeader ? (string) $request->header($tsHeader) : null;
if (!$sig || !$ts) {
abort(401, 'En-têtes de signature manquants');
}
$skew = (int) ($cfg['clock_skew'] ?? 300);
if (abs(now()->timestamp - (int) $ts) > $skew) {
abort(401, 'Horodatage hors fenêtre');
}
$algo = $cfg['algo'] ?? 'sha256';
if (!in_array($algo, hash_hmac_algos(), true)) {
abort(500, 'Algorithme HMAC non supporté');
}
$secret = (string) $cfg['secret'];
$expected = hash_hmac($algo, $ts . '.' . $raw, $secret);
// Certains providers envoient un header structuré (ex: "t=...,v1=...,v0=...").
$sigValue = $sig;
if (str_contains($sig, ',')) {
$pairs = collect(explode(',', $sig))
->map(fn ($p) => array_map('trim', explode('=', $p, 2)))
->filter(fn ($p) => count($p) === 2)
->mapWithKeys(fn ($p) => [$p[0] => $p[1]]);
$sigValue = $pairs['v1'] ?? $pairs['signature'] ?? $sig;
}
if (!hash_equals($expected, $sigValue)) {
abort(401, 'Signature invalide');
}
$request->attributes->set('verified_signature', $sigValue);
$request->attributes->set('client_ip', $request->ip());
return $next($request);
}
}
Ce middleware garantit que seuls les appels signés par le provider sont acceptés et attache au passage la signature vérifiée et l’IP au Request pour être exploitées plus loin.
Route et contrôleur d’entrée
Exposez une route publique pour recevoir les webhooks et appliquez une limitation de débit simple ainsi que le middleware de signature. Le contrôleur crée (ou réutilise) une ligne WebhookCall et dispatche un job afin de répondre immédiatement.
Route:
<?php
use App\Http\Controllers\WebhookController;
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/{source}', [WebhookController::class, 'store'])
->middleware(['throttle:120,1', VerifyWebhookSignature::class]);
Contrôleur:
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookCall;
use App\Models\WebhookCall;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class WebhookController extends Controller
{
public function store(Request $request, string $source): Response
{
if (!config("webhooks.sources.$source")) {
abort(404, 'Source inconnue');
}
// Le payload brut a été signé et vérifié; on peut parser en confiance.
$payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$sig = (string) $request->attributes->get('verified_signature');
$ip = (string) $request->attributes->get('client_ip');
// Idempotence: on privilégie l'ID fourni par la source, sinon la signature.
$externalId = data_get($payload, config("webhooks.sources.$source.id_path"));
$lookup = ['source' => $source];
if ($externalId) {
$lookup['external_id'] = (string) $externalId;
} else {
$lookup['signature'] = $sig;
}
$call = WebhookCall::firstOrCreate(
$lookup,
[
'signature' => $sig,
'payload' => $payload,
'ip' => $ip,
'status' => 'pending',
'received_at' => now(),
]
);
if ($call->wasRecentlyCreated) {
ProcessWebhookCall::dispatch($call)->onQueue('webhooks');
}
return response('', 204);
}
}
Ce contrôleur est idempotent: une même combinaison (source + external_id) ou (source + signature) ne crée pas de doublon et le job n’est dispatché qu’à la première réception.
Traitement asynchrone idempotent (Job)
Le job exécute la logique métier en arrière-plan avec backoff et retries. Pour garantir l’idempotence, il verrouille la ligne en base, vérifie si elle a déjà été traitée, puis exécute le handler adéquat et met à jour le statut.
Commande à exécuter:
php artisan make:job ProcessWebhookCall
Job:
<?php
namespace App\Jobs;
use App\Models\WebhookCall;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Throwable;
class ProcessWebhookCall implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public array $backoff = [1, 5, 20, 60];
public int $tries = 5;
public function __construct(public WebhookCall $call)
{
$this->onQueue('webhooks');
}
public function handle(): void
{
DB::transaction(function () {
$call = WebhookCall::whereKey($this->call->id)->lockForUpdate()->firstOrFail();
if ($call->processed_at) {
return;
}
$typePath = config("webhooks.sources.{$call->source}.type_path");
$type = data_get($call->payload, $typePath);
$handlerClass = data_get(config("webhooks.handlers.{$call->source}"), $type);
if (!$handlerClass) {
logger()->info('Webhook ignoré: type non géré', [
'webhook_id' => $call->id,
'source' => $call->source,
'type' => $type,
]);
$call->forceFill([
'status' => 'processed',
'processed_at' => now(),
'error' => null,
])->save();
return;
}
try {
app($handlerClass)($call);
$call->forceFill([
'status' => 'processed',
'processed_at' => now(),
'error' => null,
])->save();
} catch (Throwable $e) {
if ($this->isRecoverable($e)) {
throw $e;
}
$call->forceFill([
'status' => 'failed',
'processed_at' => now(),
'error' => sprintf('%s: %s', get_class($e), $e->getMessage()),
])->save();
$this->fail($e);
}
});
}
protected function isRecoverable(Throwable $e): bool
{
$nonRecoverable = [
\Illuminate\Validation\ValidationException::class,
\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class,
];
foreach ($nonRecoverable as $class) {
if ($e instanceof $class) {
return false;
}
}
return true;
}
}
Dans cet exemple, les exceptions considérées non récupérables marquent la ligne comme failed et interrompent les retries. Les erreurs transitoires (ex: dépassement de délai API) seront relancées pour bénéficier du backoff.
Registre des handlers par type d’évènement
Le mapping des types d’évènements vers des classes invocables vous permet d’isoler proprement la logique métier. Déclarez les handlers par source dans la config, puis implémentez des classes __invoke(WebhookCall $call) focalisées.
Configuration (déjà illustrée) dans config/webhooks.php:
<?php
return [
// ...
'handlers' => [
'stripe' => [
'invoice.paid' => App\Webhook\Stripe\InvoicePaid::class,
'customer.subscription.deleted' => App\Webhook\Stripe\SubscriptionCancelled::class,
],
],
];
Handler d’exemple pour Stripe: facture payée.
<?php
namespace App\Webhook\Stripe;
use App\Models\User;
use App\Models\WebhookCall;
class InvoicePaid
{
public function __invoke(WebhookCall $call): void
{
$data = $call->payload;
$customerId = data_get($data, 'data.object.customer');
$invoiceId = data_get($data, 'data.object.id');
$amount = data_get($data, 'data.object.amount_paid');
if (!$customerId || !$invoiceId) {
throw new \InvalidArgumentException('Évènement incomplet');
}
$user = User::where('stripe_id', $customerId)->first();
if ($user && method_exists($user, 'invoices')) {
$user->invoices()->updateOrCreate(
['external_id' => $invoiceId],
[
'amount' => $amount,
'status' => 'paid',
'paid_at' => now(),
]
);
}
}
}
Handler d’exemple pour Stripe: abonnement résilié.
<?php
namespace App\Webhook\Stripe;
use App\Models\User;
use App\Models\WebhookCall;
class SubscriptionCancelled
{
public function __invoke(WebhookCall $call): void
{
$data = $call->payload;
$subscriptionId = data_get($data, 'data.object.id');
$customerId = data_get($data, 'data.object.customer');
if (!$subscriptionId) {
throw new \InvalidArgumentException('Subscription ID manquant');
}
$user = User::where('stripe_id', $customerId)->first();
if ($user && method_exists($user, 'subscriptions')) {
$user->subscriptions()
->where('external_id', $subscriptionId)
->update(['status' => 'cancelled', 'ended_at' => now()]);
}
}
}
Ces handlers ne mutent pas le WebhookCall lui-même; c’est le job qui l’actualise en success ou en échec.
Tests: signature, idempotence, file
Une suite de tests de bout en bout vous protège contre les régressions. Testez les signatures valides, l’idempotence, les horodatages expirés, les signatures invalides, et le comportement en cas de type inconnu. Utilisez RefreshDatabase et Queue::fake pour vérifier le dispatch de job, puis exécutez le job pour valider le statut final.
Exemple de tests PHPUnit:
<?php
namespace Tests\Feature;
use App\Jobs\ProcessWebhookCall;
use App\Models\WebhookCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class WebhookTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set('webhooks.sources.stripe', [
'secret' => 'whsec_test_123',
'sig_header' => 'Stripe-Signature',
'ts_header' => 'Stripe-Timestamp',
'algo' => 'sha256',
'clock_skew' => 300,
'id_path' => 'id',
'type_path' => 'type',
]);
config()->set('webhooks.handlers.stripe', [
'invoice.paid' => \App\Webhook\Stripe\InvoicePaid::class,
]);
}
protected function sign(string $raw, int $ts): string
{
$secret = config('webhooks.sources.stripe.secret');
return hash_hmac('sha256', $ts . '.' . $raw, $secret);
}
public function test_valid_signature_is_accepted_and_job_dispatched(): void
{
Queue::fake();
$payload = ['id' => 'evt_1', 'type' => 'invoice.paid', 'data' => ['object' => ['id' => 'in_1']]];
$raw = json_encode($payload);
$ts = now()->timestamp;
$sig = $this->sign($raw, $ts);
$this->postJson('/webhooks/stripe', $payload, [
'Stripe-Timestamp' => (string) $ts,
'Stripe-Signature' => $sig,
])->assertNoContent();
$this->assertDatabaseCount('webhook_calls', 1);
Queue::assertPushed(ProcessWebhookCall::class);
}
public function test_idempotence_prevents_duplicates(): void
{
Queue::fake();
$payload = ['id' => 'evt_dup', 'type' => 'invoice.paid'];
$raw = json_encode($payload);
$ts = now()->timestamp;
$sig = $this->sign($raw, $ts);
$headers = ['Stripe-Timestamp' => (string) $ts, 'Stripe-Signature' => $sig];
$this->postJson('/webhooks/stripe', $payload, $headers)->assertNoContent();
$this->postJson('/webhooks/stripe', $payload, $headers)->assertNoContent();
$this->assertDatabaseCount('webhook_calls', 1);
Queue::assertPushed(ProcessWebhookCall::class, 1);
}
public function test_rejects_old_timestamp(): void
{
$payload = ['id' => 'evt_old', 'type' => 'invoice.paid'];
$raw = json_encode($payload);
$ts = now()->subMinutes(15)->timestamp;
$sig = $this->sign($raw, $ts);
$this->postJson('/webhooks/stripe', $payload, [
'Stripe-Timestamp' => (string) $ts,
'Stripe-Signature' => $sig,
])->assertUnauthorized();
}
public function test_rejects_invalid_signature(): void
{
$payload = ['id' => 'evt_bad', 'type' => 'invoice.paid'];
$ts = now()->timestamp;
$this->postJson('/webhooks/stripe', $payload, [
'Stripe-Timestamp' => (string) $ts,
'Stripe-Signature' => 'invalid',
])->assertUnauthorized();
}
public function test_unknown_type_is_marked_processed_without_handler(): void
{
$payload = ['id' => 'evt_unknown', 'type' => 'unknown.type'];
$raw = json_encode($payload);
$ts = now()->timestamp;
$sig = $this->sign($raw, $ts);
$this->postJson('/webhooks/stripe', $payload, [
'Stripe-Timestamp' => (string) $ts,
'Stripe-Signature' => $sig,
])->assertNoContent();
$call = WebhookCall::first();
$this->assertNotNull($call);
(new ProcessWebhookCall($call))->handle();
$call->refresh();
$this->assertEquals('processed', $call->status);
}
}
Ces tests montrent que la signature valide autorise l’insertion et le dispatch du job, qu’une seconde livraison ne crée pas de doublon, que les horodatages expirés et signatures invalides sont rejetés, et qu’un type inconnu est marqué processed sans handler.
Durcissement et observabilité
Le throttling est un premier rempart contre les abus. Vous pouvez créer un rate limiter par source et IP pour mieux contrôler le flux. Dans un provider (par exemple App\Providers\RouteServiceProvider), définissez un limiter nommé, puis utilisez throttle:webhooks sur la route.
Rate limiter personnalisé:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('webhooks', function (Request $request) {
$source = (string) $request->route('source');
$ip = $request->ip();
return Limit::perMinute(120)->by($source . ':' . $ip);
});
}
}
Adaptez ensuite la route:
Route::post('/webhooks/{source}', [WebhookController::class, 'store'])
->middleware(['throttle:webhooks', VerifyWebhookSignature::class]);
Une protection de replay supplémentaire est possible en refusant un timestamp déjà vu avec la même signature. Dans notre implémentation, la signature vérifiée est persistée et unique par source, ce qui évite les duplications involontaires. Vous pouvez renforcer encore ce mécanisme en ajoutant un TTL de rejet par signature si la politique du provider le permet.
Un filtrage par IP est utile lorsque le provider publie une liste d’adresses source. Vérifiez cependant la stabilité de ces listes et le risque d’évolution. Une approche pratique consiste à charger une liste d’IP autorisées depuis la config et à abort(403) au niveau du middleware si l’IP du client n’y figure pas.
La journalisation contextuelle facilite l’investigation. Ajoutez un contexte global au moment du traitement, en incluant l’ID de la ligne, la source et l’ID externe. Par exemple, avant d’appeler le handler dans le job, utilisez:
logger()->withContext([
'webhook_id' => $call->id,
'source' => $call->source,
'external_id' => $call->external_id,
]);
Le monitoring avec Horizon est fortement recommandé. Paramétrez des tableaux de bord pour suivre le taux de succès, la latence, les retries et les échecs. Définissez des alertes si le nombre de failed dépasse un seuil ou si des pending anciens s’accumulent, afin de réagir rapidement en cas d’incident.
Une commande d’entretien permet de relancer des appels bloqués. Créez une commande artisan webhooks:requeue-stuck qui re-dispatch les pending plus anciens qu’un certain délai. Cela facilite la remédiation manuelle lors d’incidents ponctuels.
Commande Artisan:
php artisan make:command RequeueStuckWebhooks
Implémentation:
<?php
namespace App\Console\Commands;
use App\Jobs\ProcessWebhookCall;
use App\Models\WebhookCall;
use Illuminate\Console\Command;
class RequeueStuckWebhooks extends Command
{
protected $signature = 'webhooks:requeue-stuck {--minutes=10}';
protected $description = 'Ré-enfile les webhooks pending plus vieux que N minutes';
public function handle(): int
{
$minutes = (int) $this->option('minutes');
$stuck = WebhookCall::query()
->where('status', 'pending')
->where('received_at', '<', now()->subMinutes($minutes))
->get();
foreach ($stuck as $call) {
ProcessWebhookCall::dispatch($call)->onQueue('webhooks');
}
$this->info("Ré-enfilés: {$stuck->count()} webhook(s).");
return self::SUCCESS;
}
}
Déploiement et check-list
Le déploiement consiste à migrer la base, mettre en cache la config, lancer les workers, fournir les secrets et tester une première livraison via l’outil du provider. Exécutez les commandes artisan pour créer la table et figer la configuration, puis démarrez vos workers sur la queue webhooks. Configurez l’URL de webhook côté provider (par exemple, https://votre-domaine.tld/webhooks/stripe) et insérez la clé secrète associée dans votre .env. Testez en production à l’aide de l’outil de re-livraison du provider; avec Stripe, stripe listen et stripe trigger permettent d’émuler des évènements et d’observer le pipeline de bout en bout.
Exemples de commandes:
php artisan migrate && php artisan config:cache
php artisan horizon
# ou
php artisan queue:work --queue=webhooks --sleep=1 --max-jobs=1000
Exemple avec Stripe CLI:
# Redirige tous les évènements vers votre endpoint
stripe listen --forward-to https://votre-domaine.tld/webhooks/stripe
# Génère un évènement de facture payée
stripe trigger invoice.paid
La validation finale porte sur l’absence de duplication en base, une latence de réponse HTTP inférieure à 200 ms, des retries opérationnels en cas d’échec transitoire, et des logs contextualisés contenant l’ID de webhook, la source et l’ID externe pour faciliter le diagnostic.
Checklist
La dernière étape consiste à relire le code pour vérifier la cohérence des chemins id_path et type_path par source, à exécuter les tests automatisés pour s’assurer que les signatures, l’idempotence et le dispatch de jobs fonctionnent, et à tester manuellement le flux avec l’outil du provider (ex: Stripe CLI) sur un environnement de préproduction ou de production contrôlé.
Une fois ces validations effectuées, publiez la fonctionnalité en production en vous assurant que Horizon ou queue:work tournent, que l’URL du webhook est bien enregistrée chez le provider, et que les variables d’environnement (secrets, QUEUE_CONNECTION, APP_URL) sont correctement renseignées et mises en cache.
Conclusion
La combinaison d’une vérification HMAC stricte, d’un enregistrement en base, d’un traitement asynchrone idempotent et de retries contrôlés transforme un simple endpoint de webhook en une brique de fiabilité. Cette architecture absorbe les doublons, les pannes transitoires et les pics de charge, tout en restant extensible via un registre de handlers par type d’évènement. Avec une observabilité correcte et quelques commandes d’entretien, votre intégration de webhooks devient robuste et maintenable.
Ressources
La documentation officielle de Laravel sur les files d’attente décrit les drivers, la configuration des workers, les retries et le backoff: https://laravel.com/docs/11.x/queues
Le guide des middlewares dans Laravel détaille la création et l’enregistrement de middlewares pour la sécurité et la validation: https://laravel.com/docs/11.x/middleware
Le chapitre sur la configuration et l’accès aux variables d’environnement aide à structurer config/webhooks.php et .env: https://laravel.com/docs/11.x/configuration
La documentation sur le Rate Limiting montre comment définir des limites personnalisées par source et IP: https://laravel.com/docs/11.x/rate-limiting
Horizon fournit un monitoring temps réel des jobs, des taux d’échec et des temps d’attente: https://laravel.com/docs/11.x/horizon
Les transactions de base de données dans Laravel assurent la cohérence lors du traitement idempotent: https://laravel.com/docs/11.x/database#database-transactions
La documentation de test de Laravel couvre les tests de fonctionnalités, Queue::fake et l’assertion du dispatch de jobs: https://laravel.com/docs/11.x/testing
Le guide Stripe sur la vérification des signatures de webhooks explique la construction de l’HMAC et les en-têtes utilisés: https://stripe.com/docs/webhooks/signatures
Stripe CLI permet d’écouter et de déclencher des évènements pour vos tests de bout en bout: https://stripe.com/docs/stripe-cli