Webhooks idempotents dans Laravel avec verrous Redis (tutoriel pratique)
On construit un endpoint webhook robuste dans Laravel: vérification de signature, déduplication en base, verrous atomiques Redis et traitement idempotent en file.
Sommaire
Webhooks idempotents dans Laravel avec verrous Redis (tutoriel pratique)
Recevoir des webhooks en production est risqué si une même notification est traitée plusieurs fois: duplicates, retries agressifs des providers, workers concurrents… Ce tutoriel montre comment bâtir un endpoint webhook robuste dans Laravel 10+, avec vérification de signature, déduplication en base, verrous atomiques Redis et traitement asynchrone idempotent en file. À la fin, le même événement n’est jamais appliqué deux fois, même sous charges et pannes partielles.
Objectif
Nous allons construire un endpoint webhook complet qui: 1) valide la signature du provider, 2) persiste l’événement pour audit et déduplication, 3) répond rapidement en 2xx, 4) déclenche un Job en file Redis, 5) s’assure de l’exclusivité via un verrou Redis, et 6) applique un traitement métier idempotent. Prenons Stripe comme exemple de signature HMAC, mais la structure fonctionne pour n’importe quel provider.
Objectif et prérequis
L’objectif est clair: ne jamais traiter deux fois le même webhook, même si le provider renvoie le même événement, si vos workers redémarrent brutalement ou si vous scalez la file. Il vous faut un projet Laravel 10+, Redis opérationnel pour cache et queue, et idéalement Horizon pour superviser les workers. Assurez-vous que la table des jobs échoués est créée et que le CSRF n’interfère pas avec la route webhook.
- Activez Redis pour le cache et la queue dans .env:
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
- Installez la table des jobs échoués puis migrez:
php artisan queue:failed-table
php artisan migrate
- Désactivez CSRF pour les webhooks (sinon 419). Deux approches valent:
- Déclarez la route dans routes/api.php (le groupe api n’a pas CSRF).
- Ou ajoutez une exception explicite:
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhooks/*',
];
- Horizon est recommandé (installation: composer require laravel/horizon), pour gérer la concurrence et le monitoring des files.
Modèle de persistance minimal pour les événements
On persiste chaque webhook dans une table dédiée avec une contrainte d’unicité sur (provider, event_id). Cela nous permet une déduplication immédiate et un audit minimal.
Créez la migration:
php artisan make:migration create_webhook_events_table
Implémentez-la:
// database/migrations/2024_01_01_000000_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('provider', 64);
$table->string('event_id', 191);
$table->json('payload')->nullable(); // Payload minimal, attention aux PII
$table->enum('status', ['received', 'processed', 'failed'])->default('received');
$table->timestamp('processed_at')->nullable();
$table->text('error')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
Optionnellement, créez un modèle pour plus de confort:
// app/Models/WebhookEvent.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = [
'provider', 'event_id', 'payload', 'status', 'processed_at', 'error',
];
protected $casts = [
'payload' => 'array',
'processed_at' => 'datetime',
];
}
Route et contrôleur: lecture du payload brut
Déclarez une route POST dédiée qui lit le corps brut (indispensable pour la vérification HMAC) et répond dès que l’événement est persisté et dispatché. Utilisez l’api.php pour éviter CSRF et sessions:
// routes/api.php
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/stripe', [WebhookController::class, 'stripe'])->name('webhooks.stripe');
Le contrôleur lit le payload brut, récupère les headers de signature puis, après vérification, persist/dispatch sans bloquer la requête:
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
public function stripe(Request $request)
{
$payload = $request->getContent(); // Ne pas utiliser $request->all()
$signatureHeader = $request->header('Stripe-Signature');
// Vérification de signature (section suivante)
if (! $this->verifyStripeSignature($payload, $signatureHeader, config('services.stripe.webhook_secret'))) {
Log::warning('Webhook Stripe signature invalid', ['ip' => $request->ip()]);
return response('Invalid signature', 400);
}
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return response('Invalid JSON', 400);
}
$provider = 'stripe';
$eventId = $data['id'] ?? null;
if (! $eventId) {
return response('Missing event id', 422);
}
// Déduplication immédiate
$inserted = DB::table('webhook_events')->insertOrIgnore([
'provider' => $provider,
'event_id' => $eventId,
'payload' => $data, // stocker une version utile mais minimale
'status' => 'received',
'created_at' => now(),
'updated_at' => now(),
]);
// Dispatch asynchrone si nouvel événement
if ($inserted) {
ProcessWebhookEvent::dispatch($provider, $eventId)->onQueue('webhooks');
return response('Accepted', 202);
}
// Événement déjà connu: idempotence => 200 immédiat
return response('OK', 200);
}
private function verifyStripeSignature(string $payload, ?string $header, string $secret, int $tolerance = 300): bool
{
if (! $header || ! $secret) {
return false;
}
// Header format: t=timestamp,v1=signature
$parts = collect(explode(',', $header))
->mapWithKeys(function ($kv) {
[$k, $v] = array_map('trim', explode('=', $kv, 2));
return [$k => $v];
});
$timestamp = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (! $timestamp || ! $v1) {
return false;
}
// Tolérance temporelle
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$signedPayload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// Comparaison temporellement sûre
return hash_equals($expected, $v1);
}
}
Note: nous vérifions la signature avant l’insertion; c’est crucial pour ne jamais persister de données forgées.
Vérification de signature (exemple HMAC/Stripe)
Stockez le secret de votre endpoint dans l’environnement et exposez-le via config/services.php pour centraliser la config.
- .env:
STRIPE_WEBHOOK_SECRET=whsec_xxx
- config/services.php:
return [
// ...
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];
Le schéma Stripe exige de calculer un HMAC SHA-256 sur "timestamp.payload" et de le comparer au champ v1 du header Stripe-Signature. Le code ci-dessus illustre ce calcul, avec validation d’un timestamp toléré (par exemple 300 secondes). En cas d’échec, contentez-vous de journaliser le minimum (IP, provider) et répondez 400 pour que le provider ne considère pas l’événement reçu.
Pour un provider HMAC plus simple (ex: Shopify, header X-Shopify-Hmac-Sha256), l’approche générique consiste à calculer le HMAC du payload brut et à comparer au header. Exemple:
private function verifyGenericHmac(string $payload, ?string $header, string $secret): bool
{
if (! $header || ! $secret) return false;
// Shopify envoie un Base64 du HMAC-SHA256 binaire
$expected = base64_encode(hash_hmac('sha256', $payload, $secret, true));
return hash_equals($expected, $header);
}
Adaptez la lecture du header selon le provider (ex: X-Shopify-Hmac-Sha256).
Déduplication immédiate via insertOrIgnore + réponse 2xx
Dès que l’événement est validé, on calcule la clé d’idempotence côté serveur: provider + event_id. Avec Stripe, event_id est la clé "id" du payload (ex: evt_123). Une simple insertion avec insertOrIgnore sur la table webhook_events nous garantit qu’un doublon renvoie 0 ligne insérée. Dans ce cas, on retourne 200 immédiatement, sans redispatcher. Cela élimine les doubles traitements même si le provider répète l’événement:
$inserted = DB::table('webhook_events')->insertOrIgnore([
'provider' => $provider,
'event_id' => $eventId,
'payload' => $data,
'status' => 'received',
'created_at' => now(),
'updated_at' => now(),
]);
if ($inserted) {
ProcessWebhookEvent::dispatch($provider, $eventId)->onQueue('webhooks');
return response('Accepted', 202);
}
return response('OK', 200);
Stockez uniquement les champs nécessaires dans payload pour l’audit et le débogage, et évitez d’enregistrer des données sensibles (PII) inutilement.
Dispatch du traitement asynchrone
On dispatch un Job en file Redis avec la clé métier (provider + event_id). On n’attend pas la fin du traitement pour répondre au provider. Le code précédent envoie le Job sur la queue nommée "webhooks" et renvoie un 202 (Accepted) si l’événement vient d’être persisté.
Assurez-vous que la queue Redis est active et qu’Horizon écoute la file "webhooks". Un exemple minimal d’ENV et d’Horizon:
QUEUE_CONNECTION=redis
// config/horizon.php (extrait)
return [
'environments' => [
'production' => [
'webhooks-supervisor' => [
'connection' => 'redis',
'queue' => ['webhooks'],
'balance' => 'auto',
'processes' => 5,
'tries' => 5,
],
],
'local' => [
'webhooks-supervisor' => [
'connection' => 'redis',
'queue' => ['webhooks'],
'balance' => 'simple',
'processes' => 2,
'tries' => 3,
],
],
],
];
Démarrez Horizon localement:
php artisan horizon
Job idempotent avec verrous atomiques Redis
Le Job doit être idempotent à l’exécution: un verrou Redis garantit l’exclusivité par event_id, et une relecture de la ligne évènement évite de retraiter un événement déjà marqué "processed". En cas de non acquisition du verrou, on relâche le Job avec un petit délai pour éviter les courses.
// app/Jobs/ProcessWebhookEvent.php
namespace App\Jobs;
use App\Models\WebhookEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\DatabaseManager;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; // alternatif
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ProcessWebhookEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $provider;
public $eventId;
public $tries = 5;
public function backoff(): array
{
return [1, 5, 15, 60];
}
public function __construct(string $provider, string $eventId)
{
$this->provider = $provider;
$this->eventId = $eventId;
$this->onQueue('webhooks');
}
public function handle(): void
{
$lockKey = "webhook:{$this->provider}:{$this->eventId}";
$lock = Cache::lock($lockKey, 30); // TTL du verrou: 30s
if (! $lock->get()) {
// Un autre worker traite déjà cet event, on réessaie plus tard
$this->release(5);
return;
}
try {
$event = WebhookEvent::where('provider', $this->provider)
->where('event_id', $this->eventId)
->first();
if (! $event) {
// Rien à faire (potentielle purge), ack
return;
}
if ($event->status === 'processed') {
// Déjà traité: idempotence
return;
}
// Traitement métier idempotent
DB::transaction(function () use ($event) {
$payload = $event->payload;
// Exemple: Stripe payment_intent.succeeded -> upsert Order
if (($payload['type'] ?? null) === 'payment_intent.succeeded') {
$pi = $payload['data']['object'] ?? [];
$externalId = $pi['id'] ?? null;
$amount = $pi['amount'] ?? null;
$currency = $pi['currency'] ?? null;
if ($externalId) {
// Empêcher doublons côté domaine: external_id unique
DB::table('orders')->updateOrInsert(
['external_id' => $externalId, 'provider' => 'stripe'],
[
'amount' => $amount,
'currency' => $currency,
'status' => 'paid',
'updated_at' => now(),
'created_at' => now(), // sera ignoré si déjà existant
]
);
}
}
// D’autres types d’événements : idem, toujours idempotents
});
$event->forceFill([
'status' => 'processed',
'processed_at' => now(),
'updated_at' => now(),
'error' => null,
])->save();
} catch (Throwable $e) {
// Relancer l’exception pour déclencher le retry/backoff
throw $e;
} finally {
optional($lock)->release();
}
}
public function failed(Throwable $e): void
{
// Dernier échec après $tries: marquer failed pour post-mortem
try {
WebhookEvent::where('provider', $this->provider)
->where('event_id', $this->eventId)
->update([
'status' => 'failed',
'error' => substr($e->getMessage(), 0, 65000),
'updated_at' => now(),
]);
} catch (Throwable $ignored) {
// Éviter un cascade failure
}
Log::error('Webhook job failed', [
'provider' => $this->provider,
'event_id' => $this->eventId,
'message' => $e->getMessage(),
]);
}
}
Remarque: Laravel propose aussi le middleware WithoutOverlapping($key) côté Job; ici nous utilisons un verrou Redis explicite pour un contrôle fin.
Transactions et effets de bord idempotents
Le cœur métier doit être encapsulé dans une transaction et s’appuyer sur des contraintes uniques pour neutraliser les duplicates. Par exemple, forcez une clé unique sur orders(external_id, provider) et privilégiez updateOrInsert/upsert/firstOrCreate plutôt que create direct. Cela garantit que rejouer le même événement met à jour l’état au lieu de dupliquer.
Exemple d’upsert (table orders avec index unique sur [provider, external_id]):
DB::table('orders')->upsert(
[
[
'provider' => 'stripe',
'external_id' => $externalId, // ex: pi_123
'amount' => $amount,
'currency' => $currency,
'status' => 'paid',
'created_at' => now(),
'updated_at' => now(),
],
],
['provider', 'external_id'], // clés uniques
['amount', 'currency', 'status', 'updated_at'] // colonnes à mettre à jour
);
Si vous appelez des APIs externes, pensez à passer une idempotency-key propre à l’événement pour éviter un double débit côté partenaire. Avec Guzzle:
use Illuminate\Support\Str;
use GuzzleHttp\Client;
$client = new Client(['timeout' => 5.0]);
$idempotencyKey = "stripe:{$event->event_id}";
$response = $client->post('https://api.externe.test/charge', [
'headers' => ['Idempotency-Key' => $idempotencyKey],
'json' => ['amount' => $amount, 'currency' => $currency],
]);
Stratégie de retry, backoff et erreurs
Le Job définit $tries = 5 et un backoff progressif [1, 5, 15, 60]. Les erreurs transitoires (timeouts réseau, deadlocks, pertes de connexion Redis) doivent simplement être relancées pour profiter du retry. Les erreurs permanentes finiront dans failed_jobs après les essais, et nous marquons l’événement "failed" avec le message d’erreur pour analyse.
Vous pouvez réessayer des jobs échoués:
php artisan queue:failed # lister
php artisan queue:retry all # relancer tout
php artisan queue:retry {id} # relancer un job précis
php artisan queue:forget {id} # supprimer un failed
Pour rejouer des événements marqués failed en base via une commande artisan dédiée, une commande simple peut faire le pont:
// app/Console/Commands/RetryFailedWebhooks.php
namespace App\Console\Commands;
use App\Jobs\ProcessWebhookEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RetryFailedWebhooks extends Command
{
protected $signature = 'webhooks:retry-failed {provider?}';
protected $description = 'Redispatch des webhooks dont le status=failed';
public function handle(): int
{
$query = DB::table('webhook_events')->where('status', 'failed');
if ($p = $this->argument('provider')) {
$query->where('provider', $p);
}
$count = 0;
$query->orderBy('id')->chunk(100, function ($rows) use (&$count) {
foreach ($rows as $row) {
DB::table('webhook_events')
->where('id', $row->id)
->update(['status' => 'received', 'error' => null, 'updated_at' => now()]);
ProcessWebhookEvent::dispatch($row->provider, $row->event_id)->onQueue('webhooks');
$count++;
}
});
$this->info("Redispatch: {$count} events");
return self::SUCCESS;
}
}
Test rapide: duplications et concurrence
Un test local simple consiste à rejouer le même payload plusieurs fois en parallèle et à vérifier qu’une seule ligne passe au status processed.
- Créez un payload factice Stripe (sans signature réelle) pour tester la déduplication applicative:
// payload.json
{
"id": "evt_test_123",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_123",
"amount": 1000,
"currency": "eur"
}
}
}
- Si vous testez la route sans vérification de signature, envoyez 3 requêtes en parallèle:
cat payload.json | xargs -I{} -P3 sh -c 'curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost/api/webhooks/stripe \
-H "Content-Type: application/json" \
--data-binary @payload.json'
Vous devriez voir un 202 puis des 200 ensuite. En base, l’event evt_test_123 n’existe qu’une fois et devient processed une seule fois.
- Pour tester la signature Stripe localement, générez un header valide avec un secret factice:
export STRIPE_WEBHOOK_SECRET="whsec_testsecret"
export T=$(date +%s)
export SIG=$(printf "%s.%s" "$T" "$(cat payload.json)" \
| openssl dgst -sha256 -hmac "$STRIPE_WEBHOOK_SECRET" -binary | xxd -p -c 256)
curl -i -X POST http://localhost/api/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=$T,v1=$SIG" \
--data-binary @payload.json
Modifiez un octet du payload et rejouez avec le même header: la route doit répondre 400.
- Simulez une course en augmentant la concurrence côté workers (ex: 10 processus Horizon) et en bombardant la même event_id avec k6. Le verrou Redis doit garantir qu’un seul worker exécute le traitement par event_id:
// k6 script: webhook.js
import http from 'k6/http';
import { check } from 'k6';
export let options = { vus: 20, iterations: 200 };
export default function () {
const url = 'http://localhost/api/webhooks/stripe';
const payload = JSON.stringify({
id: 'evt_race_456',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_race_456', amount: 500, currency: 'eur' } }
});
const headers = { 'Content-Type': 'application/json' }; // en dev sans signature
const res = http.post(url, payload, { headers });
check(res, { '2xx': (r) => r.status >= 200 && r.status < 300 });
}
Exécutez: k6 run webhook.js et observez dans la base qu’un seul traitement passe à processed.
Durcissement et checklist de prod
En production, verrouillez l’entrée et la concurrence: limitez la taille du corps, refusez les content-types inattendus, raccourcissez les timeouts côté contrôleur et n’effectuez aucune I/O externe dans la requête HTTP du webhook (uniquement persistance + dispatch). Conservez des logs sobres et anonymisés incluant provider, event_id et status. Côté Horizon, isolez la queue webhooks et limitez sa concurrence pour mieux contrôler la charge; surveillez la latence et les temps d’acquisition de verrous. Mettez en place des alertes sur les taux d’échec, une hausse anormale de duplicates et les verrous qui expirent fréquemment pendant le traitement. Enfin, documentez les schémas d’événements supportés, les clés d’idempotence utilisées et la stratégie de replay interne.
Pour isoler la concurrence webhooks dans Horizon:
// config/horizon.php (extrait)
'webhooks-supervisor' => [
'queue' => ['webhooks'],
'processes' => 5, // ajustez selon charge
'balance' => 'auto',
'tries' => 5,
],
Et côté Nginx/Ingress, limitez la taille du corps pour les webhooks (ex: client_max_body_size 512k) et forcez Content-Type: application/json.
Checklist
Avant publication, relisez l’ensemble (migrations, contrôleur, verrou, job) et testez chaque snippet en local: déduplication, signature valide/invalide, retries et backoff, concurrence avec plusieurs workers. Une fois validé, déployez, branchez Horizon et ajoutez les métriques/alertes nécessaires pour suivre le taux de succès, la latence et les erreurs.
Conclusion
En combinant vérification HMAC, insertion idempotente en base, verrous Redis et traitement asynchrone, vous obtenez un pipeline de webhooks résilient: acceptation rapide côté HTTP, zéro double-application métier et une traçabilité nette des échecs. Cette approche est simple à maintenir et s’adapte bien aux charges variables grâce à Horizon et Redis. Adaptez les snippets au provider de votre choix et enrichissez le traitement métier avec des upserts/transactions au plus près de vos invariants.
Ressources
- Laravel – Queues: https://laravel.com/docs/10.x/queues
- Laravel – Cache locks (verrous atomiques): https://laravel.com/docs/10.x/cache#atomic-locks
- Laravel Horizon: https://laravel.com/docs/10.x/horizon
- Stripe – Verifying signatures: https://stripe.com/docs/webhooks/signatures
- Shopify – Webhook verification: https://shopify.dev/docs/apps/build/webhooks/verify
- MySQL – INSERT ... ON DUPLICATE KEY UPDATE / upsert: https://laravel.com/docs/10.x/queries#upserts
- Guzzle – Idempotency-Key patterns: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-01