Skip to content

Webhook Component — Centraliser la réception des webhooks

TL;DR — Le composant symfony/webhook, stable depuis Symfony 7.0, fournit une infrastructure unifiée pour recevoir, valider et traiter les webhooks de fournisseurs tiers (Stripe, GitHub, Mailgun, Twilio, Slack…). Il s'intègre nativement avec symfony/remote-event (modèle d'événement neutre) et symfony/messenger (traitement asynchrone, retry, DLQ). À la place d'un controller fait main par provider — avec sa propre logique de signature HMAC, son parsing JSON et son routing — on déclare une route unique /webhook/{type}, on implémente un RequestParserInterface par fournisseur, et le composant fait le reste : validation cryptographique timing-safe, dispatch d'un RemoteEvent, traitement asynchrone via Messenger. Le gain : code de réception 3 à 5× plus court, signature et idempotency mutualisées, observabilité Messenger gratuite.

🧠 Mental model — ASCII + analogie

Avant le composant — un controller par provider

POST /webhooks/stripe       →  StripeWebhookController
POST /webhooks/github       →  GitHubWebhookController
POST /webhooks/mailgun      →  MailgunWebhookController
POST /webhooks/twilio       →  TwilioWebhookController
...

Chaque controller :

  • décode le body
  • vérifie la signature (HMAC SHA-256 souvent, mais chaque provider a son format)
  • check de fraîcheur (timestamp dans la signature)
  • match sur le type d'événement
  • appelle un handler métier
  • gère idempotency manuellement
  • répond 2xx / 4xx avec format custom

C'est du code répétitif et critique en sécurité. Une seule mauvaise comparaison == (au lieu de hash_equals()) et c'est une timing attack ouverte.

Avec le composant Webhook

                  ┌──────────────────────────────────────┐
                  │  Symfony Webhook Controller          │
                  │  (route /webhook/{type})             │
                  └──────────────────────┬───────────────┘

                          ┌──────────────▼──────────────┐
                          │  WebhookRequestParser        │
                          │  (1 implémentation/provider) │
                          │  ─ validate signature        │
                          │  ─ parse JSON                │
                          │  ─ retourne RemoteEvent      │
                          └──────────────┬───────────────┘

                          ┌──────────────▼──────────────┐
                          │  RemoteEvent                 │
                          │  (type, id, payload)         │
                          └──────────────┬───────────────┘

                            ┌────────────▼────────────┐
                            │  Messenger transport    │
                            │  (async, retry, DLQ)    │
                            └────────────┬────────────┘

                            ┌────────────▼────────────┐
                            │  RemoteEventHandler     │
                            │  (votre code métier)    │
                            └─────────────────────────┘

L'analogie : voyez symfony/webhook comme la réception d'un hôtel. Avant, chaque comptoir avait son propre vigile (controller), sa propre liste de réservations (signature/parsing) et son propre escalier vers les chambres (handlers). Maintenant, il y a une réception unique, un standard de vérification d'identité (RequestParserInterface), un système central de messages internes (RemoteEvent + Messenger), et chaque chambre (handler) reçoit son message via le pneumatique. Si la chambre est occupée (handler en erreur), le message attend dans la pile (retry), puis va aux objets trouvés (DLQ) si toujours impossible.

🛠️ Code minimal (PHP 8.2+)

1. Installation

bash
composer require symfony/webhook symfony/remote-event symfony/messenger

2. Configuration

yaml
# config/packages/webhook.yaml
framework:
    webhook:
        routing:
            stripe:
                service: 'App\Webhook\StripeRequestParser'
                secret: '%env(STRIPE_WEBHOOK_SECRET)%'
            github:
                service: 'App\Webhook\GitHubRequestParser'
                secret: '%env(GITHUB_WEBHOOK_SECRET)%'
            mailgun:
                service: 'App\Webhook\MailgunRequestParser'
                secret: '%env(MAILGUN_WEBHOOK_SECRET)%'

Cela enregistre automatiquement la route :

POST /webhook/stripe
POST /webhook/github
POST /webhook/mailgun

3. Un RequestParser pour Stripe

php
<?php
// src/Webhook/StripeRequestParser.php
declare(strict_types=1);

namespace App\Webhook;

use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final class StripeRequestParser extends AbstractRequestParser
{
    private const SIGNATURE_HEADER = 'Stripe-Signature';
    private const TOLERANCE_SECONDS = 300; // 5 min

    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
            new IsJsonRequestMatcher(),
        ]);
    }

    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $signatureHeader = $request->headers->get(self::SIGNATURE_HEADER);
        if (!$signatureHeader) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Missing Stripe-Signature header.');
        }

        $payload = $request->getContent();
        $this->validateSignature($signatureHeader, $payload, $secret);

        try {
            $data = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Invalid JSON payload.', $e);
        }

        $type = $data['type'] ?? null;
        $id = $data['id'] ?? null;

        if (!\is_string($type) || !\is_string($id)) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Missing type or id in payload.');
        }

        return new RemoteEvent($type, $id, $data);
    }

    private function validateSignature(string $header, string $payload, string $secret): void
    {
        // Format Stripe : "t=1701234567,v1=abc123...,v0=..."
        $parts = [];
        foreach (explode(',', $header) as $item) {
            [$k, $v] = explode('=', $item, 2) + [null, null];
            $parts[$k] = $v;
        }

        $timestamp = (int) ($parts['t'] ?? 0);
        $signature = $parts['v1'] ?? '';

        if (!$timestamp || !$signature) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Malformed signature header.');
        }

        // anti-replay : rejette signatures trop vieilles
        if (abs(time() - $timestamp) > self::TOLERANCE_SECONDS) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Signature too old (replay protection).');
        }

        $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);

        // hash_equals est timing-safe ; ne JAMAIS utiliser ==
        if (!hash_equals($expected, $signature)) {
            throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid signature.');
        }
    }
}

4. Un RemoteEventHandler pour traiter

php
<?php
// src/RemoteEvent/StripeWebhookHandler.php
declare(strict_types=1);

namespace App\RemoteEvent;

use App\Repository\PaymentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;

#[AsRemoteEventConsumer('stripe')]
final class StripeWebhookHandler implements ConsumerInterface
{
    public function __construct(
        private readonly PaymentRepository $payments,
        private readonly LoggerInterface $logger,
    ) {}

    public function consume(RemoteEvent $event): void
    {
        // ⚠️ Idempotency NAÏVE (check-then-act) : montrée ici pour la lisibilité,
        // MAIS race condition entre 2 workers (cf. pitfall #4 et la section
        // "Comment un staff engineer raisonne là-dessus"). En prod, utilisez
        // un tryClaim() atomique reposant sur une contrainte unique DB.
        if ($this->payments->hasProcessedWebhook($event->getId())) {
            $this->logger->info('Webhook already processed', ['id' => $event->getId()]);
            return;
        }

        match ($event->getName()) {
            'payment_intent.succeeded' => $this->onPaymentSucceeded($event),
            'payment_intent.payment_failed' => $this->onPaymentFailed($event),
            'charge.refunded' => $this->onRefunded($event),
            default => $this->logger->info('Stripe event ignored', ['type' => $event->getName()]),
        };

        $this->payments->markWebhookProcessed($event->getId());
    }

    private function onPaymentSucceeded(RemoteEvent $event): void
    {
        $payload = $event->getPayload();
        $paymentIntentId = $payload['data']['object']['id'] ?? null;

        if ($payment = $this->payments->findByPaymentIntentId($paymentIntentId)) {
            $payment->markPaid();
        }
    }

    private function onPaymentFailed(RemoteEvent $event): void { /* … */ }
    private function onRefunded(RemoteEvent $event): void { /* … */ }
}

5. Routage Messenger pour traiter en async

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            webhook_async: '%env(MESSENGER_TRANSPORT_DSN)%'
            failed: 'doctrine://default?queue_name=failed'

        routing:
            'Symfony\Component\RemoteEvent\Messenger\ConsumeRemoteEventMessage': webhook_async

        failure_transport: failed

Lancement du worker :

bash
bin/console messenger:consume webhook_async --time-limit=3600 --memory-limit=128M -vv

6. RequestParser pour GitHub (autre format de signature)

php
<?php
// src/Webhook/GitHubRequestParser.php
declare(strict_types=1);

namespace App\Webhook;

use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final class GitHubRequestParser extends AbstractRequestParser
{
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
            new IsJsonRequestMatcher(),
        ]);
    }

    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $signature = $request->headers->get('X-Hub-Signature-256', '');
        $eventType = $request->headers->get('X-GitHub-Event', '');
        $deliveryId = $request->headers->get('X-GitHub-Delivery', '');

        if (!$signature || !$eventType || !$deliveryId) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Missing required GitHub headers.');
        }

        $payload = $request->getContent();
        $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

        if (!hash_equals($expected, $signature)) {
            throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid signature.');
        }

        try {
            $data = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Invalid JSON.', $e);
        }

        return new RemoteEvent($eventType, $deliveryId, $data);
    }
}

🎯 Patterns courants

1. Idempotency garantie via la base

Les providers renvoient les webhooks en cas d'erreur côté receveur. Sans idempotency, vous risquez de doubler un paiement, créer deux utilisateurs, etc.

php
<?php
// src/Repository/WebhookLogRepository.php
namespace App\Repository;

use Doctrine\DBAL\Connection;

final class WebhookLogRepository
{
    public function __construct(private readonly Connection $db) {}

    public function markProcessed(string $provider, string $eventId): bool
    {
        try {
            $this->db->insert('webhook_log', [
                'provider' => $provider,
                'event_id' => $eventId,
                'processed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
            ]);
            return true;
        } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
            return false; // déjà traité
        }
    }
}

Avec une contrainte SQL :

sql
CREATE UNIQUE INDEX uniq_webhook_log_provider_event
    ON webhook_log(provider, event_id);

L'idempotency doit reposer sur une contrainte DB, pas sur un SELECT puis INSERT (race condition possible entre 2 workers).

2. Réponse immédiate, traitement différé

Stripe et GitHub attendent une réponse 2xx rapidement (souvent < 10s) sinon ils marquent le webhook comme échoué et le renvoient. La pattern :

  1. RequestParser valide la signature et construit le RemoteEvent (rapide).
  2. Le composant dispatch un ConsumeRemoteEventMessage sur Messenger (instantané).
  3. Le controller webhook renvoie immédiatement 202 Accepted.
  4. Un worker Messenger consomme le message plus tard et exécute le RemoteEventHandler.

Cela découple totalement la latence du provider de la latence de votre traitement métier.

3. Retry exponentiel + DLQ

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            webhook_async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 5
                    delay: 5000          # 5s
                    multiplier: 3        # 5s → 15s → 45s → 2m15 → 6m45
                    max_delay: 600000    # cap à 10 min
            failed:
                dsn: 'doctrine://default?queue_name=failed'

Inspecter les échecs :

bash
bin/console messenger:failed:show
bin/console messenger:failed:retry --force
bin/console messenger:failed:remove 42

4. Multi-tenant — un secret par client

php
<?php
final class TenantStripeRequestParser extends AbstractRequestParser
{
    public function __construct(private readonly TenantResolver $tenants) {}

    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        // Le `secret` vient de la config, mais on peut surcharger par tenant
        $tenantId = $request->headers->get('X-Tenant-Id');
        $secret = $this->tenants->getStripeSecret($tenantId);

        // ... validation
    }
}

5. Tests d'intégration sans exposer le secret

Voir section Testing.

6. Logs structurés et observabilité

php
<?php
$this->logger->info('Webhook received', [
    'provider' => 'stripe',
    'event_type' => $event->getName(),
    'event_id' => $event->getId(),
    'received_at' => time(),
]);

Avec Monolog formatter JSON + agrégateur (Loki, Datadog, Sentry), vous voyez en un coup d'œil les webhooks rejetés, lents ou échoués.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x + libs

VersionÉtat du composant
Symfony 5.4 LTSPas de webhook component. Implémenter à la main ou via bundle communautaire (incenteev/composer-parameter-handler, etc.).
Symfony 6.3Première version expérimentale de symfony/webhook et symfony/remote-event (marqué @experimental). API instable.
Symfony 6.4 LTSWebhook & RemoteEvent toujours @experimental. Utilisables mais BC breaks possibles.
Symfony 7.0Stabilisation des composants webhook et remote-event. API garantie.
Symfony 7.1Ajout de RequestMatcher chainables, meilleurs hooks d'erreur, intégration Notifier.
Symfony 7.2AsRemoteEventConsumer attribut PHP, registry de consumers automatisée.
Symfony 7.3+Polish : amélioration debug:messenger, support Stripe/GitHub natif côté Notifier (envoi).
symfony/notifier6.0+ couvre l'envoi Stripe/Mailgun. Le côté réception est symfony/webhook (depuis 6.3, stable 7.0).

En 2026, si vous êtes sur 6.4 LTS : utilisable mais surveiller la CHANGELOG. Si vous êtes sur 7.x, vous bénéficiez de l'API stable et des intégrations natives (Stripe, Mailgun, etc. ont leurs parsers fournis).

Parsers fournis nativement

Depuis Symfony 7.0, plusieurs bridges fournissent leur RequestParser :

  • symfony/mailgun-mailerMailgunRequestParser
  • symfony/mailchimp-mailer (transactional → Mandrill) → parser dédié
  • symfony/postmark-mailerPostmarkRequestParser
  • symfony/twilio-notifierTwilioRequestParser
  • (et d'autres ajoutés progressivement par la communauté)

Pour Stripe et GitHub, vous écrivez encore les vôtres — mais c'est trivial avec l'AbstractRequestParser.

⚠️ Pitfalls — 10 pièges réels

  1. Comparaison non timing-safe. == ou === sur des HMAC ouvre une timing attack. Toujours hash_equals(). C'est non négociable en cryptographie.

  2. Pas de protection contre replay. Une signature valide reste valide. Sans vérification de timestamp (Stripe t=) ou d'un delivery_id unique (GitHub), un attaquant qui capture une requête peut la rejouer indéfiniment.

  3. Lire $request->getContent() après l'avoir consommé. En PHP, le stream input est consommé une fois. Si un middleware/listener appelle json_decode($request->getContent()) avant votre parser, vous obtenez "". Toujours utiliser Request::getContent() qui buffer en interne.

  4. Idempotency basée sur un SELECT puis INSERT. Race condition entre 2 workers. Utiliser une contrainte unique en DB + catch de la violation.

  5. Bloquer dans doParse(). Le parsing doit être rapide (< 100ms). Pas d'appel HTTP externe, pas de requête DB lourde. Le traitement lent va dans le handler async.

  6. Oublier le secret dans .env. Le webhook répond 401 silencieusement, le provider marque l'app comme cassée. Toujours logger la cause du RejectWebhookException (sans révéler le secret).

  7. Type de transport Messenger mal choisi. sync désactive l'async (mauvais pour la latence). in_memory perd les messages au crash. Utiliser Doctrine, Redis Stream ou AMQP, jamais sync en prod.

  8. Pas de monitoring du DLQ. Si le failed transport grossit, vous perdez de l'argent (paiements non-comptabilisés, emails non-traités). Alerter sur messenger:failed:show --max 100.

  9. Webhooks reçus pendant un déploiement. Si vous coupez l'app, le provider retente. Mais si Stripe retente 5 fois en 5 min et que votre app est down 10 min, c'est perdu. Mitigation : webhook receiver doit être toujours up (séparer du déploiement applicatif si possible).

  10. CSRF / firewall qui bloquent. Le firewall Symfony peut intercepter POST /webhook/* et exiger CSRF / authent. Toujours exclure la route du firewall :

yaml
# config/packages/security.yaml
security:
    firewalls:
        webhook:
            pattern: ^/webhook/
            security: false
        main:
            # ...

🧪 Testing

Test unitaire du parser

php
<?php
// tests/Webhook/StripeRequestParserTest.php
declare(strict_types=1);

namespace App\Tests\Webhook;

use App\Webhook\StripeRequestParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final class StripeRequestParserTest extends TestCase
{
    private const SECRET = 'whsec_test_dummy';

    public function testValidSignatureProducesRemoteEvent(): void
    {
        $payload = json_encode(['id' => 'evt_123', 'type' => 'payment_intent.succeeded']);
        $timestamp = time();
        $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", self::SECRET);

        $request = Request::create('/webhook/stripe', 'POST', [], [], [], [], $payload);
        $request->headers->set('Content-Type', 'application/json');
        $request->headers->set('Stripe-Signature', "t={$timestamp},v1={$signature}");

        $parser = new StripeRequestParser();
        $event = $parser->parse($request, self::SECRET);

        self::assertInstanceOf(RemoteEvent::class, $event);
        self::assertSame('payment_intent.succeeded', $event->getName());
        self::assertSame('evt_123', $event->getId());
    }

    public function testInvalidSignatureIsRejected(): void
    {
        $payload = '{"id":"evt_x","type":"payment_intent.succeeded"}';
        $request = Request::create('/webhook/stripe', 'POST', [], [], [], [], $payload);
        $request->headers->set('Content-Type', 'application/json');
        $request->headers->set('Stripe-Signature', 't=1,v1=bogus');

        $this->expectException(RejectWebhookException::class);
        (new StripeRequestParser())->parse($request, self::SECRET);
    }

    public function testReplayProtectionRejectsOldTimestamps(): void
    {
        $payload = '{"id":"evt_x","type":"x"}';
        $oldTimestamp = time() - 3600;
        $signature = hash_hmac('sha256', "{$oldTimestamp}.{$payload}", self::SECRET);

        $request = Request::create('/webhook/stripe', 'POST', [], [], [], [], $payload);
        $request->headers->set('Content-Type', 'application/json');
        $request->headers->set('Stripe-Signature', "t={$oldTimestamp},v1={$signature}");

        $this->expectException(RejectWebhookException::class);
        $this->expectExceptionMessageMatches('/replay/i');
        (new StripeRequestParser())->parse($request, self::SECRET);
    }
}

Test d'intégration du handler

php
<?php
// tests/RemoteEvent/StripeWebhookHandlerTest.php
declare(strict_types=1);

namespace App\Tests\RemoteEvent;

use App\RemoteEvent\StripeWebhookHandler;
use App\Repository\PaymentRepository;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\RemoteEvent\RemoteEvent;

final class StripeWebhookHandlerTest extends TestCase
{
    public function testSucceededPaymentMarksRecordPaid(): void
    {
        $payments = $this->createMock(PaymentRepository::class);
        $payments->method('hasProcessedWebhook')->willReturn(false);

        $payment = new \stdClass();
        $payment->paid = false;

        $payments->expects(self::once())
            ->method('findByPaymentIntentId')
            ->with('pi_123')
            ->willReturn($payment);

        $payments->expects(self::once())->method('markWebhookProcessed');

        $event = new RemoteEvent('payment_intent.succeeded', 'evt_42', [
            'data' => ['object' => ['id' => 'pi_123']],
        ]);

        (new StripeWebhookHandler($payments, new NullLogger()))->consume($event);
    }

    public function testIdempotencySkipsAlreadyProcessed(): void
    {
        $payments = $this->createMock(PaymentRepository::class);
        $payments->method('hasProcessedWebhook')->willReturn(true);
        $payments->expects(self::never())->method('findByPaymentIntentId');

        $event = new RemoteEvent('payment_intent.succeeded', 'evt_42', []);
        (new StripeWebhookHandler($payments, new NullLogger()))->consume($event);
    }
}

Test end-to-end via WebTestCase

php
<?php
// tests/Integration/StripeWebhookEndToEndTest.php
declare(strict_types=1);

namespace App\Tests\Integration;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

final class StripeWebhookEndToEndTest extends WebTestCase
{
    public function testWebhookIsRoutedAndQueued(): void
    {
        $client = static::createClient();
        $secret = $client->getContainer()->getParameter('app.stripe_webhook_secret');

        $payload = json_encode(['id' => 'evt_e2e', 'type' => 'payment_intent.succeeded']);
        $timestamp = time();
        $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);

        $client->request(
            method: 'POST',
            uri: '/webhook/stripe',
            server: [
                'CONTENT_TYPE' => 'application/json',
                'HTTP_STRIPE_SIGNATURE' => "t={$timestamp},v1={$signature}",
            ],
            content: $payload,
        );

        self::assertSame(202, $client->getResponse()->getStatusCode());

        /** @var InMemoryTransport $transport */
        $transport = self::getContainer()->get('messenger.transport.webhook_async');
        self::assertCount(1, $transport->getSent());
    }
}

Test manuel avec curl

bash
PAYLOAD='{"id":"evt_manual","type":"payment_intent.succeeded"}'
TS=$(date +%s)
SECRET="whsec_test_dummy"
SIG=$(echo -n "${TS}.${PAYLOAD}" | openssl dgst -sha256 -hmac "${SECRET}" | awk '{print $2}')

curl -X POST https://localhost/webhook/stripe \
    -H "Content-Type: application/json" \
    -H "Stripe-Signature: t=${TS},v1=${SIG}" \
    -d "${PAYLOAD}"

Pour tester avec les vrais providers depuis votre poste de dev, utiliser :

  • Stripe CLI : stripe listen --forward-to localhost/webhook/stripe
  • smee.io (GitHub) : smee --target https://localhost/webhook/github
  • ngrok ou tailscale funnel pour exposer publiquement.

🎬 Cas d'usage concrets

Webhook Stripe pour FinTech

Une FinTech française propose un service d'abonnement SaaS pour la gestion comptable des indépendants. Les paiements mensuels passent par Stripe Billing, et chaque cycle déclenche une cascade d'événements webhooks que la plateforme doit traiter de manière fiable : invoice.created, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, charge.refunded, etc. Avant l'adoption de symfony/webhook, l'app utilisait un controller maison de 400 lignes qui validait la signature, parsait le JSON, dispatchait à un handler synchrone. Plusieurs incidents : un == au lieu de hash_equals avait introduit une vulnérabilité timing attack (corrigée en urgence), un retry Stripe avait dédupliqué un paiement par accident (double facturation client), et le code devenait illisible avec 30+ types d'événements gérés. La migration vers symfony/webhook a permis (1) une validation cryptographique structurée via AbstractRequestParser::doParse() avec protection replay 300s, (2) un dispatch automatique via Messenger vers un transport webhook_async (RabbitMQ persistant), (3) un retry exponentiel avec DLQ pour les erreurs transitoires, (4) une idempotency stricte via contrainte unique webhook_log(provider, event_id). Le code de réception est passé à 80 lignes pour le parser + 120 lignes pour les handlers métier divisés par type d'événement. Les SLA de traitement webhook ont été tenus à 99.97% sur 12 mois, avec un MTTR de 4 minutes en moyenne pour les rares incidents (vs 35 minutes auparavant grâce à l'observabilité Messenger).

Webhook GitHub pour cabinet juridique

Un cabinet d'avocats spécialisé en droit de la tech assiste ses clients sur leurs questions de licences open source, RGPD et propriété intellectuelle. L'équipe a développé un outil interne d'audit automatique : quand un client autorise l'accès à un de ses dépôts GitHub via une GitHub App, l'outil reçoit des webhooks à chaque push, PR, release, et analyse les changements (nouveaux fichiers de licence, modifications de package.json, mention de dépendances problématiques, ajout de personnal data dans le code). Chaque webhook GitHub a sa propre signature (X-Hub-Signature-256 HMAC SHA-256), son delivery ID unique (X-GitHub-Delivery) et son type d'événement (X-GitHub-Event). Le composant symfony/webhook permet de traiter cette diversité élégamment : un GitHubRequestParser unique valide la signature, vérifie le delivery ID contre la table d'idempotency, et construit un RemoteEvent typé. Côté handlers, des consumers spécialisés (PushEventHandler, PullRequestEventHandler, ReleaseEventHandler) traitent chacun leur logique d'audit. Si l'analyse détecte un risque juridique (ajout d'une dépendance AGPL dans un projet propriétaire client, par exemple), un email est envoyé à l'avocat en charge avec un rapport détaillé. La sécurité est critique : le webhook reçoit des informations sensibles (contenu de code client), donc le firewall Symfony exclut la route /webhook/github du routage standard et un middleware vérifie que l'IP source appartient bien à la plage GitHub publiée (en plus de la signature HMAC). Le cabinet a sécurisé 12 clients enterprise sur ce service en 18 mois, avec une marge nette supérieure aux missions classiques.

Webhook Mirakl pour e-commerce marketplace

Un retailer e-commerce français exploite une marketplace multi-vendeurs basée sur la plateforme Mirakl. Les vendeurs (sellers) gèrent leurs catalogues, leurs stocks et leurs commandes via Mirakl, et le retailer reçoit des webhooks pour de nombreux événements : nouvelle offre soumise, validation/refus de catalogue, expédition de commande, demande de retour, message client, etc. Mirakl envoie ses webhooks avec une signature HMAC SHA-256 dans le header X-Mirakl-Signature et un identifiant de delivery dans X-Mirakl-Delivery-Id. La volumétrie est conséquente : 500 à 2000 webhooks/jour selon la saison. L'équipe a structuré le traitement avec symfony/webhook en suivant le pattern recommandé : MiraklRequestParser qui valide la signature et construit le RemoteEvent, dispatch via Messenger vers webhook_mirakl_async (Doctrine queue pour la simplicité), et 4 consumers spécialisés par grande famille d'événements (catalogue, commandes, retours, messagerie). Pour les événements de stock (impact direct sur la dispo affichée aux clients), un traitement prioritaire via un transport séparé webhook_critical traité par 4 workers dédiés assure une latence inférieure à 30 secondes entre webhook reçu et stock à jour côté front. La DLQ est monitorée par un dashboard Grafana avec alerting Pagerduty si plus de 5 messages échouent dans une fenêtre de 10 minutes. Bonus apprécié : la table webhook_log permet de rejouer manuellement n'importe quel event en cas de bug applicatif (commande bin/console app:webhook:replay --provider=mirakl --event-id=xxx), ce qui a sauvé la mise à plusieurs reprises lors d'incidents techniques. L'architecture supporte aujourd'hui sereinement les pics du Black Friday avec 15 000 webhooks reçus en 4 heures.

🛠️ Exemple end-to-end

Pipeline complet de webhook Stripe : parser avec replay protection, handler async avec idempotency DB stricte, retry exponentiel, et tests d'intégration.

php
<?php
// src/Webhook/StripeRequestParser.php
declare(strict_types=1);

namespace App\Webhook;

use Psr\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final readonly class StripeRequestParser extends AbstractRequestParser
{
    private const SIGNATURE_HEADER = 'Stripe-Signature';
    private const TOLERANCE_SECONDS = 300;

    public function __construct(private ClockInterface $clock) {}

    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
            new IsJsonRequestMatcher(),
        ]);
    }

    protected function doParse(
        Request $request,
        #[\SensitiveParameter] string $secret,
    ): ?RemoteEvent {
        $sigHeader = $request->headers->get(self::SIGNATURE_HEADER)
            ?? throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Missing Stripe-Signature header',
            );

        $parts = $this->parseSignatureHeader($sigHeader);
        $payload = $request->getContent();

        $this->validateTimestamp($parts['t']);
        $this->validateSignature($parts['t'], $payload, $parts['v1'], $secret);

        try {
            $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Invalid JSON',
                $e,
            );
        }

        $type = $data['type'] ?? null;
        $id = $data['id'] ?? null;

        if (!is_string($type) || !is_string($id)) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Missing type or id',
            );
        }

        return new RemoteEvent($type, $id, $data);
    }

    /** @return array{t: int, v1: string} */
    private function parseSignatureHeader(string $header): array
    {
        $parts = [];
        foreach (explode(',', $header) as $item) {
            $kv = explode('=', $item, 2);
            if (count($kv) === 2) {
                $parts[$kv[0]] = $kv[1];
            }
        }

        if (!isset($parts['t'], $parts['v1'])) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Malformed signature header',
            );
        }

        return ['t' => (int) $parts['t'], 'v1' => $parts['v1']];
    }

    private function validateTimestamp(int $timestamp): void
    {
        $now = $this->clock->now()->getTimestamp();
        if (abs($now - $timestamp) > self::TOLERANCE_SECONDS) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Signature too old (replay protection)',
            );
        }
    }

    private function validateSignature(
        int $timestamp,
        string $payload,
        string $signature,
        #[\SensitiveParameter] string $secret,
    ): void {
        $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
        if (!hash_equals($expected, $signature)) {
            throw new RejectWebhookException(
                Response::HTTP_UNAUTHORIZED,
                'Invalid signature',
            );
        }
    }
}
php
<?php
// src/RemoteEvent/StripeWebhookHandler.php
declare(strict_types=1);

namespace App\RemoteEvent;

use App\Repository\WebhookLogRepository;
use App\Service\SubscriptionService;
use Psr\Log\LoggerInterface;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;

#[AsRemoteEventConsumer('stripe')]
final readonly class StripeWebhookHandler implements ConsumerInterface
{
    public function __construct(
        private WebhookLogRepository $log,
        private SubscriptionService $subscriptions,
        private LoggerInterface $logger,
    ) {}

    public function consume(RemoteEvent $event): void
    {
        // Idempotency stricte via contrainte unique DB
        if (!$this->log->tryClaim('stripe', $event->getId())) {
            $this->logger->info('Webhook already claimed', [
                'event_id' => $event->getId(),
            ]);
            return;
        }

        try {
            match ($event->getName()) {
                'invoice.payment_succeeded' => $this->onPaymentSucceeded($event),
                'invoice.payment_failed' => $this->onPaymentFailed($event),
                'customer.subscription.updated' => $this->onSubscriptionUpdated($event),
                'customer.subscription.deleted' => $this->onSubscriptionDeleted($event),
                'charge.refunded' => $this->onRefunded($event),
                default => $this->logger->debug('Ignored event', [
                    'type' => $event->getName(),
                ]),
            };

            $this->log->markCompleted('stripe', $event->getId());
        } catch (\Throwable $e) {
            $this->log->markFailed('stripe', $event->getId(), $e->getMessage());
            throw $e;  // re-throw pour que Messenger retry
        }
    }

    private function onPaymentSucceeded(RemoteEvent $event): void
    {
        $invoice = $event->getPayload()['data']['object'] ?? [];
        $this->subscriptions->markInvoicePaid(
            invoiceId: $invoice['id'],
            amountPaid: $invoice['amount_paid'] ?? 0,
            paidAt: new \DateTimeImmutable('@'.($invoice['status_transitions']['paid_at'] ?? time())),
        );
    }

    private function onPaymentFailed(RemoteEvent $event): void
    {
        $invoice = $event->getPayload()['data']['object'] ?? [];
        $this->subscriptions->markInvoiceFailed(
            invoiceId: $invoice['id'],
            failureReason: $invoice['last_finalization_error']['message'] ?? 'unknown',
        );
    }

    private function onSubscriptionUpdated(RemoteEvent $event): void
    {
        $sub = $event->getPayload()['data']['object'] ?? [];
        $this->subscriptions->syncFromStripe($sub);
    }

    private function onSubscriptionDeleted(RemoteEvent $event): void
    {
        $sub = $event->getPayload()['data']['object'] ?? [];
        $this->subscriptions->cancel($sub['id']);
    }

    private function onRefunded(RemoteEvent $event): void
    {
        $charge = $event->getPayload()['data']['object'] ?? [];
        $this->subscriptions->recordRefund(
            chargeId: $charge['id'],
            refundedAmount: $charge['amount_refunded'] ?? 0,
        );
    }
}
php
<?php
// src/Repository/WebhookLogRepository.php
declare(strict_types=1);

namespace App\Repository;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Psr\Clock\ClockInterface;

final readonly class WebhookLogRepository
{
    public function __construct(
        private Connection $db,
        private ClockInterface $clock,
    ) {}

    public function tryClaim(string $provider, string $eventId): bool
    {
        try {
            $this->db->insert('webhook_log', [
                'provider' => $provider,
                'event_id' => $eventId,
                'status' => 'processing',
                'claimed_at' => $this->clock->now()->format('Y-m-d H:i:s'),
            ]);
            return true;
        } catch (UniqueConstraintViolationException) {
            return false;
        }
    }

    public function markCompleted(string $provider, string $eventId): void
    {
        $this->db->update('webhook_log',
            [
                'status' => 'completed',
                'completed_at' => $this->clock->now()->format('Y-m-d H:i:s'),
            ],
            ['provider' => $provider, 'event_id' => $eventId],
        );
    }

    public function markFailed(string $provider, string $eventId, string $reason): void
    {
        $this->db->update('webhook_log',
            [
                'status' => 'failed',
                'failure_reason' => substr($reason, 0, 500),
            ],
            ['provider' => $provider, 'event_id' => $eventId],
        );
    }
}
yaml
# config/packages/webhook.yaml
framework:
    webhook:
        routing:
            stripe:
                service: 'App\Webhook\StripeRequestParser'
                secret: '%env(STRIPE_WEBHOOK_SECRET)%'
yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            webhook_async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 5
                    delay: 5000
                    multiplier: 3
                    max_delay: 600000
            failed: 'doctrine://default?queue_name=failed'
        routing:
            'Symfony\Component\RemoteEvent\Messenger\ConsumeRemoteEventMessage': webhook_async
        failure_transport: failed
sql
CREATE TABLE webhook_log (
    id BIGSERIAL PRIMARY KEY,
    provider VARCHAR(50) NOT NULL,
    event_id VARCHAR(200) NOT NULL,
    status VARCHAR(20) NOT NULL,
    claimed_at TIMESTAMP,
    completed_at TIMESTAMP,
    failure_reason TEXT,
    CONSTRAINT uniq_provider_event UNIQUE (provider, event_id)
);

CREATE INDEX idx_webhook_log_status_claimed
    ON webhook_log (status, claimed_at);

Tests d'intégration end-to-end : un test envoie un POST sur /webhook/stripe avec une signature valide générée à la volée, vérifie la réponse 202, contrôle qu'un message a bien été déposé dans le transport webhook_async, puis exécute manuellement le worker (messenger:consume webhook_async --limit=1) et vérifie que le handler a appliqué l'effet métier attendu (abonnement marqué payé, ligne webhook_log en statut completed). Un second test rejoue le même webhook et vérifie que rien ne se reproduit (idempotency). Un troisième test envoie une signature invalide et attend un 401.

🧭 Comment un staff engineer raisonne là-dessus

Idempotency : claim-then-complete vs check-then-process — le vrai tradeoff

Le code minimal (section 4) fait hasProcessedWebhook() puis markWebhookProcessed(). Le code end-to-end fait mieux : tryClaim() insère une ligne processing dès le départ (atomique via contrainte unique), traite, puis markCompleted(). Les deux ont des failure modes différents qu'il faut savoir nommer en design review :

StratégieAt-least-once garanti ?Failure mode
check-then-process (SELECT puis INSERT)NonRace entre 2 workers → double traitement (deux paiements). À bannir.
claim-then-complete (INSERT processing puis UPDATE completed)OuiCrash entre claim et complete → la ligne reste processing pour toujours, le retry Messenger est bloqué (le re-claim échoue), l'effet métier n'a jamais eu lieu. Poison silencieux.
claim + visibilité timeoutOuiSi le worker crashe, un autre peut re-claim après un TTL (ex. claimed_at < now() - 5min AND status = 'processing'). C'est la version prod-grade.

Le piège du claim-then-complete naïf est subtil : il échange une race condition (double traitement) contre un blocage permanent (zéro traitement). Pour un système de paiement, zéro traitement est souvent pire qu'un double traitement détecté. La version robuste autorise le re-claim d'une ligne processing périmée :

php
public function tryClaim(string $provider, string $eventId): bool
{
    // Insert ou "vol" d'un claim périmé en une seule requête atomique (PostgreSQL)
    $affected = $this->db->executeStatement(
        <<<'SQL'
        INSERT INTO webhook_log (provider, event_id, status, claimed_at)
        VALUES (:p, :e, 'processing', :now)
        ON CONFLICT (provider, event_id) DO UPDATE
            SET status = 'processing', claimed_at = :now
            WHERE webhook_log.status = 'processing'
              AND webhook_log.claimed_at < :stale
        SQL,
        [
            'p' => $provider,
            'e' => $eventId,
            'now' => $this->clock->now()->format('Y-m-d H:i:s'),
            'stale' => $this->clock->now()->modify('-5 minutes')->format('Y-m-d H:i:s'),
        ],
    );

    return $affected === 1; // 0 si déjà 'completed' ou claim frais d'un autre worker
}

La leçon transférable : toute idempotency at-least-once a besoin d'un état terminal ET d'un mécanisme de récupération de claim orphelin. Sans le second, vous avez juste déplacé le bug.

Le webhook receiver est un système de paiement déguisé

Un staff engineer traite l'endpoint webhook avec la même rigueur qu'une caisse enregistreuse, parce que c'en est une : chaque message manqué ou doublé est de l'argent. Cela impose trois invariants non négociables, dans cet ordre de priorité :

  1. Durabilité avant tout — accepter (202) et persister le message AVANT tout traitement métier. Si le transport Messenger est in_memory ou sync, un crash perd des paiements. Doctrine/Redis-Stream/AMQP uniquement.
  2. At-least-once + idempotency — vous ne pouvez pas avoir exactly-once en système distribué. Vous l'approximez avec at-least-once (retry) + idempotency (dédup). C'est un théorème, pas une opinion.
  3. Observabilité du DLQ — le failed transport est l'endroit où l'argent disparaît. Il doit avoir une alerte, pas juste un log.

Pourquoi le parsing doit être trivial (et ne JAMAIS toucher la DB métier)

doParse() tourne dans le cycle requête HTTP synchrone du provider. Stripe coupe à ~10-20s ; au-delà il considère le webhook échoué et le rejoue — créant une rafale de doublons exactement quand votre système est déjà lent. Toute latence dans doParse() (appel HTTP de résolution de tenant, requête DB non indexée, déchiffrement lourd) se transforme en tempête de retries amplifiée. Règle : doParse() ne fait que de la crypto et du JSON en mémoire ; tout le reste va dans le handler async. Le seul accès externe tolérable est un lookup de secret en cache (multi-tenant), et encore — préférez un cache chaud.

🔁 Quand utiliser / éviter

Utiliser symfony/webhook quand

  • Vous recevez des webhooks de plusieurs providers dans la même app.
  • Vous voulez un traitement async robuste (Messenger + retry + DLQ) avec un minimum de glue code.
  • Vous voulez la sécurité gérée (signature timing-safe, replay protection structurée).
  • Vous utilisez déjà Symfony Notifier (envoi) — la cohérence côté réception est naturelle.
  • Vous êtes sur Symfony 7.x (stable) ou 6.4 LTS (avec acceptation de l'@experimental).

Préférer un controller custom quand

  • Vous avez un seul webhook, ultra-simple, sans signature à valider — un mini controller suffit.
  • Vous êtes sur Symfony 5.4 LTS sans possibilité d'upgrader rapidement (le composant n'existe pas).
  • Le provider utilise un protocole non-HTTP standard (gRPC, AMQP-direct, etc.) — le composant webhook est HTTP-only.
  • Vous avez des besoins très spécifiques non couverts par AbstractRequestParser (ex. parsing multipart complexe, sessions, etc.).
  • Vous voulez du streaming bidirectionnel (rare pour les webhooks, mais possible).

Custom controllers vs Webhook component — tableau comparatif

AspectController customsymfony/webhook
Lignes de code~150-300 par provider~50-100 par provider
Signature timing-safeÀ faire manuellementStructure imposée (utilise hash_equals)
IdempotencyÀ faire manuellementÀ faire manuellement (mais structuré)
Async via MessengerManuelNatif
Retry / DLQManuelNatif (Messenger)
ObservabilitéManuelleMessenger + Profiler
Test infraCustomRemoteEvent/MessengerTestCase
Découverte automatiqueNonRoutes auto-générées
Cohérence multi-providerÀ enforcer en code reviewImposée par l'API

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Faites-les dans l'ordre, chacun s'appuie sur le précédent.

1. Parser Mailgun avec signature HMAC (implémenter)

Objectif : écrire un MailgunRequestParser qui valide la signature Mailgun et produit un RemoteEvent.

Mailgun signe différemment de Stripe : le payload JSON contient signature: { timestamp, token, signature }, et la signature attendue est hash_hmac('sha256', $timestamp.$token, $secret). Le token sert d'identifiant unique anti-replay (à usage unique).

Indice/Solution : extraire $data['signature']['timestamp'], ['token'], ['signature']. Vérifier hash_equals(hash_hmac('sha256', $ts.$token, $secret), $sig). Utiliser $token comme RemoteEvent id (pas un champ id). Rejeter si abs(now - $ts) > 300. Le type d'événement est dans $data['event-data']['event'].

2. Idempotency atomique multi-worker (production-grade)

Objectif : remplacer le hasProcessedWebhook() naïf de la section 4 par un tryClaim() atomique sûr face à 4 workers concurrents.

Écrivez le repository avec la requête INSERT ... ON CONFLICT DO UPDATE ... WHERE status='processing' AND claimed_at < stale montrée plus haut, puis prouvez-le : un test qui lance 4 appels consume() en parallèle (via pcntl_fork ou simplement 4 transactions concurrentes) sur le même event_id et assert qu'un seul applique l'effet métier.

Indice/Solution : la clé est que la dédup repose sur la contrainte UNIQUE, jamais sur un SELECT. Le executeStatement() retourne le nombre de lignes affectées : 1 = vous avez le claim, 0 = un autre l'a (ou c'est déjà completed). Pour le test concurrent, le plus simple est un transport Doctrine réel + messenger:consume avec --limit lancé en 4 process.

3. Réponse 202 immédiate + traitement async (production-grade)

Objectif : garantir que /webhook/stripe répond en < 50ms même quand le handler met 8s.

Configurez le routing Messenger pour que ConsumeRemoteEventMessage parte sur un transport webhook_async (Doctrine), ajoutez un sleep(8) dans le handler, et mesurez le temps de réponse HTTP avec curl -w '%{time_total}'. Vérifiez que la réponse est 202 instantanée et que le travail se fait dans le worker.

Indice/Solution : si le temps de réponse inclut les 8s, c'est que le transport est resté sync (routing absent ou mal nommé — la FQCN doit être exactement Symfony\Component\RemoteEvent\Messenger\ConsumeRemoteEventMessage). Vérifiez avec bin/console debug:messenger et messenger:stats.

4. Casser puis réparer : le claim orphelin (break-then-fix)

Objectif : reproduire le blocage permanent du claim-then-complete naïf, puis le corriger.

Partez du tryClaim() qui ne fait qu'un INSERT simple (sans ON CONFLICT ... WHERE stale). Dans le handler, lancez une exception après le claim mais avant markCompleted(). Observez : Messenger retry, mais chaque retry re-claim → UniqueConstraintViolationException → le message est traité comme "déjà fait" et passe en silence, alors que l'effet métier n'a JAMAIS eu lieu. Le paiement est perdu silencieusement. Réparez avec la version ON CONFLICT DO UPDATE ... WHERE status='processing' AND claimed_at < stale.

Indice/Solution : le symptôme en prod est « le webhook est marqué traité mais l'abonnement n'est pas payé ». La cause racine est qu'un état processing non terminal a été interprété comme terminal. Le fix réintroduit la récupération de claim périmé (TTL). Bonus : ajoutez une alerte sur SELECT count(*) FROM webhook_log WHERE status='processing' AND claimed_at < now() - interval '15 minutes'.

5. Casser puis réparer : la timing attack (break-then-fix, sécurité)

Objectif : démontrer pourquoi == est exploitable et hash_equals ne l'est pas.

Écrivez un micro-benchmark : comparez deux signatures de 64 hex avec == puis avec hash_equals, en mesurant hrtime(true) sur 1M itérations pour des signatures qui diffèrent au 1er caractère vs au 60e. Avec == (court-circuit byte-par-byte) le temps varie ; avec hash_equals (temps constant) il ne varie pas. Concluez sur l'oracle de timing qu'un attaquant exploite pour forger une signature octet par octet.

Indice/Solution : == sur strings en PHP s'arrête au premier octet différent → fuite de la position de divergence. hash_equals compare tous les octets en XOR-accumulant. La démo réelle est bruitée (jitter réseau >> jitter CPU), mais le principe est non négociable en code review : un HMAC comparé avec == est un bug de sécurité bloquant, pas un nit.

6. Replay & ordering : les événements arrivent dans le désordre (architecture)

Objectif : gérer le cas où customer.subscription.deleted arrive AVANT customer.subscription.updated (Stripe ne garantit pas l'ordre).

Les providers livrent en at-least-once sans ordre garanti. Un updated (ancien) traité après un deleted (récent) peut ressusciter un abonnement annulé. Concevez une stratégie : soit ignorer les events plus vieux que l'état courant (versioning par event.created timestamp ou par numéro de version Stripe), soit re-fetcher l'état canonique via l'API Stripe au lieu de faire confiance au payload.

Indice/Solution : la solution prod-grade chez les gros utilisateurs Stripe est « le webhook est un signal, pas une source de vérité » : à réception, on appelle stripe.subscriptions.retrieve($id) pour obtenir l'état actuel et on réconcilie. Le payload du webhook peut être périmé au moment où le worker le traite. Alternativement, stocker last_event_at par entité et rejeter tout payload dont le timestamp est antérieur.

🎤 En entretien

Q : Pourquoi traiter les webhooks en asynchrone via Messenger plutôt que synchrone dans le controller ? Parce que la latence du provider et la latence métier doivent être découplées : Stripe coupe à ~10s et rejoue en cas de timeout, donc un handler lent ou une DB sous charge déclenche une tempête de doublons. On valide + persiste (202) en < 100ms, puis on traite dans un worker avec retry/DLQ. Bonus : le crash d'un handler ne renvoie pas une 5xx au provider qui amplifierait les retries.

Q : Comment garantir l'idempotency, et pourquoi un SELECT suivi d'un INSERT ne suffit pas ? On dédup sur une contrainte UNIQUE (provider, event_id) et on catch la UniqueConstraintViolationException — l'atomicité vient de la DB, pas du code applicatif. Le SELECT puis INSERT a une fenêtre de race entre deux workers concurrents qui passent tous deux le SELECT avant que l'un insère, d'où double traitement. La version complète utilise un état processing → completed avec récupération de claim orphelin (TTL) pour ne pas bloquer le retry après un crash.

Q : Pourquoi hash_equals() et pas === pour comparer une signature HMAC ?===/== court-circuite au premier octet différent, ce qui crée un oracle de timing : un attaquant mesure le temps de réponse pour deviner la signature octet par octet et finir par la forger. hash_equals() compare en temps constant (tous les octets, quelle que soit la position de divergence). C'est un invariant de sécurité bloquant, pas un détail de style.

Q : Le RemoteEvent que vous recevez est-il une source de vérité fiable ? Non. Les webhooks sont at-least-once et sans ordre garanti ; le payload peut être périmé quand le worker le traite (un updated ancien après un deleted récent). Le pattern robuste est « le webhook est un signal de réveil » : on re-fetch l'état canonique via l'API du provider, ou on versionne par timestamp/numéro de version et on rejette les events périmés. Faire confiance aveuglément au payload réintroduit des bugs de cohérence subtils.

🔗 Liens


Récap final

Le composant symfony/webhook, stabilisé en Symfony 7.0, est aujourd'hui le moyen recommandé de recevoir et traiter des webhooks tiers (Stripe, GitHub, Mailgun, Twilio…). Il fournit une architecture standardisée : RequestParserInterface par provider pour valider la signature et construire un RemoteEvent neutre, puis dispatch automatique via symfony/messenger pour un traitement asynchrone, retryable, observabilisable. Les bénéfices : code 3 à 5× plus court, sécurité cryptographique structurée (hash_equals, protection replay), et cohérence entre tous vos endpoints webhooks. Quatre règles d'or : (1) toujours valider la signature avec hash_equals(), jamais == ; (2) toujours protéger contre le replay (timestamp ou delivery-id unique) ; (3) toujours garantir l'idempotency via une contrainte DB sur l'event_id ; (4) toujours traiter en async via Messenger pour répondre 202 sous 100ms au provider. Pour la migration : sur Symfony 7.x, démarrer directement avec le composant. Sur 6.4 LTS, c'est utilisable mais surveiller les BC. Sur 5.4 LTS, rester sur des controllers maison ou un bundle communautaire. Et toujours monitorer le DLQ Messenger — c'est là que se cachent les paiements perdus et les emails non traités.

Bibliothèque tech perso — Achref