Aller au contenu principal
Retour au blog

Symfony Webhook Component : valider Stripe, GitHub et Slack

Flavien Métivier24 décembre 202412 min

Reçois un webhook Stripe à 3 h du matin, le job de paiement échoue, et tu découvres que le « système de webhooks maison » de ton prédécesseur se résume à un file_get_contents('php://input') suivi d'un json_decode sans validation de signature. Tout développeur Symfony senior a vécu ce moment. Depuis Symfony 6.3, le composant Webhook propose une réponse officielle à ce problème récurrent : une abstraction pour ingérer, valider et router des événements entrants de services tiers. En Symfony 7.2 — sorti le 26 novembre 2024 — il est mature, bien documenté, et mérite d'être adopté sur tout nouveau projet.

Ce que le composant Webhook règle — et ce qu'il ne prétend pas faire

Le composant Webhook n'est pas un framework d'intégration all-in-one. Son périmètre est volontairement précis : recevoir une requête HTTP entrante, vérifier sa signature cryptographique via un RequestParser dédié, convertir le payload en objet RemoteEvent typé, puis le dispatcher vers un handler métier. La protection contre le replay (contrôle du timestamp), la gestion des réponses de rejet (400/403) et l'intégration avec l'autowiring Symfony sont incluses d'emblée. Ce que le composant ne fait pas nativement : gérer les retry, mettre les événements en file d'attente asynchrone, ou fournir des parsers prêts à l'emploi pour chaque service tiers — c'est ta responsabilité, et c'est précisément ce que l'on va construire.

  • Validation de signature HMAC — le secret reste en variable d'environnement, jamais dans le code
  • Routing HTTP vers le bon parser selon le préfixe d'URL (/webhook/{type})
  • Conversion en RemoteEvent typé — tes handlers sont des classes PHP ordinaires, testables unitairement
  • Réponse 400 ou 403 automatique en cas d'échec de validation, sans polluer ton code métier
  • Intégration avec l'autowiring Symfony — aucune configuration manuelle des services

Installation et configuration en deux fichiers

composer require symfony/webhook symfony/remote-event

Le composant s'appuie sur symfony/remote-event pour le typage des événements. Deux fichiers de configuration suffisent : la déclaration de la route entrante, puis la table de correspondance entre type de webhook, parser et secret.

# config/routes/webhook.yaml
webhook:
    path: /webhook/{type}
    controller: Symfony\Component\Webhook\Controller\WebhookController
    methods: POST
# 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)%'
            slack:
                service: App\Webhook\SlackRequestParser
                secret: '%env(SLACK_SIGNING_SECRET)%'

La lecture est immédiate : l'URL /webhook/stripe appelle StripeRequestParser avec le secret injecté depuis l'environnement. Le WebhookController, orchestré par Symfony, fait le reste — il délègue au parser, dispatch le RemoteEvent produit, et intercepte toute RejectWebhookException pour renvoyer la bonne réponse HTTP sans que ton code métier ne soit jamais impliqué.

RequestParser Stripe : validation HMAC pas à pas

Stripe signe ses webhooks avec un header Stripe-Signature au format t={timestamp},v1={signature}. La signature est un HMAC-SHA256 calculé sur la chaîne {timestamp}.{payload}, le secret étant celui généré par le dashboard Stripe pour cet endpoint. La protection contre le replay repose sur la fraîcheur du timestamp : Stripe recommande de rejeter tout événement dont le timestamp dépasse cinq minutes. On implémente tout cela dans doParseRequest(), méthode abstraite de AbstractRequestParser.

<?php
// src/Webhook/StripeRequestParser.php

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
{
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
            new IsJsonRequestMatcher(),
        ]);
    }

    protected function doParseRequest(Request $request, string $secret): RemoteEvent
    {
        $signatureHeader = $request->headers->get('Stripe-Signature', '');
        $payload         = $request->getContent(); // lire en premier, avant tout autre traitement

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

        $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);

        return new RemoteEvent(
            name:    $data['type'] ?? 'unknown',
            id:      $data['id']   ?? uniqid('stripe_', true),
            payload: $data,
        );
    }

    private function validateSignature(string $header, string $payload, string $secret): void
    {
        $params = [];
        foreach (explode(',', $header) as $part) {
            [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
            $params[$key]  = $value;
        }

        $timestamp = $params['t']  ?? null;
        $signature = $params['v1'] ?? null;

        if (!$timestamp || !$signature) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Header Stripe-Signature absent ou malformé.'
            );
        }

        // Protection replay : 5 minutes max (standard Stripe)
        if (abs(time() - (int) $timestamp) > 300) {
            throw new RejectWebhookException(
                Response::HTTP_FORBIDDEN,
                'Webhook expiré : timestamp trop ancien (> 5 min).'
            );
        }

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

        if (!hash_equals($expected, $signature)) {
            throw new RejectWebhookException(
                Response::HTTP_FORBIDDEN,
                'Signature Stripe invalide.'
            );
        }
    }
}

Besoin d'un expert Symfony ?

Réserver un appel

Même pattern, même rigueur pour GitHub et Slack

GitHub signe avec X-Hub-Signature-256: sha256={signature}, HMAC-SHA256 du payload brut. Différence notable par rapport à Stripe : GitHub n'inclut pas de timestamp dans son header de signature — il n'y a pas de protection replay intégrée côté protocole. Slack va plus loin que les deux : sa signature couvre simultanément la version, le timestamp et le body (v0:{timestamp}:{body}), le timestamp étant transmis dans un header séparé X-Slack-Request-Timestamp. Voici le parser GitHub :

<?php
// src/Webhook/GithubRequestParser.php

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 doParseRequest(Request $request, string $secret): RemoteEvent
    {
        $rawSig     = $request->headers->get('X-Hub-Signature-256', '');
        $payload    = $request->getContent();
        $eventType  = $request->headers->get('X-GitHub-Event', 'unknown');
        $deliveryId = $request->headers->get('X-GitHub-Delivery', uniqid('gh_', true));

        if (!str_starts_with($rawSig, 'sha256=')) {
            throw new RejectWebhookException(
                Response::HTTP_BAD_REQUEST,
                'Header X-Hub-Signature-256 absent ou malformé.'
            );
        }

        $signature = substr($rawSig, 7);
        $expected  = hash_hmac('sha256', $payload, $secret);

        if (!hash_equals($expected, $signature)) {
            throw new RejectWebhookException(
                Response::HTTP_FORBIDDEN,
                'Signature GitHub invalide.'
            );
        }

        $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);

        return new RemoteEvent(
            name:    "github.{$eventType}",
            id:      $deliveryId,
            payload: $data,
        );
    }
}

Pour Slack, le parser applique le même squelette mais avec une chaîne à signer enrichie. La validation porte sur v0:{X-Slack-Request-Timestamp}:{body} et la signature attendue commence par v0=. La protection replay y est native — le timestamp est dans un header dédié, pas dans le body — ce qui rend Slack légèrement plus robuste que GitHub sur ce point. Le pattern reste identique : extraire les headers, vérifier la fraîcheur, recalculer hash_hmac('sha256', ...), comparer avec hash_equals().

Brancher un handler métier typé

Une fois le RemoteEvent produit par le parser, le WebhookController le dispatche vers tous les consumers décorés avec #[AsRemoteEventConsumer]. L'attribut prend le nom de la route webhook configurée — 'stripe', 'github', 'slack' — et Symfony résout la dépendance automatiquement via l'autowiring. Le handler est une classe PHP ordinaire : injectable, testable unitairement, sans aucun couplage au protocole HTTP.

<?php
// src/RemoteEvent/StripeWebhookConsumer.php

namespace App\RemoteEvent;

use App\Service\SubscriptionService;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;

#[AsRemoteEventConsumer('stripe')]
final class StripeWebhookConsumer implements ConsumerInterface
{
    public function __construct(
        private readonly SubscriptionService $subscriptions,
    ) {}

    public function consume(RemoteEvent $event): void
    {
        match ($event->getName()) {
            'payment_intent.succeeded'      => $this->onPaymentSuccess($event->getPayload()),
            'customer.subscription.deleted' => $this->onSubscriptionCancelled($event->getPayload()),
            default                         => null, // événements non gérés ignorés proprement
        };
    }

    private function onPaymentSuccess(array $payload): void
    {
        $intentId = $payload['data']['object']['id'] ?? null;
        $amount   = $payload['data']['object']['amount'] ?? 0;
        $currency = $payload['data']['object']['currency'] ?? 'eur';

        $this->subscriptions->activate($intentId, $amount, $currency);
    }

    private function onSubscriptionCancelled(array $payload): void
    {
        $customerId = $payload['data']['object']['customer'] ?? null;
        $this->subscriptions->deactivate($customerId);
    }
}

Tester sans mock HTTP lourd

C'est l'avantage le plus sous-estimé du composant : le RequestParser reçoit un objet Symfony\Component\HttpFoundation\Request standard. Tu construis les requêtes de test en mémoire avec Request::create() — pas de serveur HTTP, pas de mock cURL, pas de fixture chargée depuis le disque. Les tests sont rapides, déterministes, et tu contrôles exactement le timestamp pour couvrir la fenêtre de replay. Voici une suite complète pour StripeRequestParser, couvrant le cas nominal et trois scénarios de rejet :

<?php
// tests/Webhook/StripeRequestParserTest.php

namespace App\Tests\Webhook;

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

final class StripeRequestParserTest extends TestCase
{
    private StripeRequestParser $parser;
    private const SECRET = 'whsec_test_supersecret_for_tests_only';

    protected function setUp(): void
    {
        $this->parser = new StripeRequestParser();
    }

    private function buildRequest(string $payload, int $timestamp, string $secret): Request
    {
        $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
        $header    = "t={$timestamp},v1={$signature}";

        $request = Request::create('/webhook/stripe', 'POST', [], [], [], [], $payload);
        $request->headers->set('Content-Type', 'application/json');
        $request->headers->set('Stripe-Signature', $header);

        return $request;
    }

    public function testParsesValidPaymentIntentEvent(): void
    {
        $payload = json_encode([
            'id'   => 'evt_3QaBCDE',
            'type' => 'payment_intent.succeeded',
            'data' => ['object' => ['amount' => 5000, 'currency' => 'eur']],
        ]);

        $event = $this->parser->parse(
            $this->buildRequest($payload, time(), self::SECRET),
            self::SECRET
        );

        $this->assertSame('payment_intent.succeeded', $event->getName());
        $this->assertSame('evt_3QaBCDE', $event->getId());
        $this->assertSame('eur', $event->getPayload()['data']['object']['currency']);
    }

    public function testRejectsExpiredTimestamp(): void
    {
        $payload   = json_encode(['id' => 'evt_old', 'type' => 'ping']);
        $staleTime = time() - 400; // plus de 5 minutes

        $this->expectException(RejectWebhookException::class);

        $this->parser->parse(
            $this->buildRequest($payload, $staleTime, self::SECRET),
            self::SECRET
        );
    }

    public function testRejectsInvalidSignature(): void
    {
        $payload = json_encode(['id' => 'evt_tampered', 'type' => 'ping']);

        $this->expectException(RejectWebhookException::class);

        $this->parser->parse(
            $this->buildRequest($payload, time(), 'wrong_secret'),
            self::SECRET
        );
    }

    public function testRejectsMissingSignatureHeader(): void
    {
        $payload = json_encode(['id' => 'evt_noheader', 'type' => 'ping']);

        $request = Request::create('/webhook/stripe', 'POST', [], [], [], [], $payload);
        $request->headers->set('Content-Type', 'application/json');
        // Header Stripe-Signature intentionnellement absent

        $this->expectException(RejectWebhookException::class);

        $this->parser->parse($request, self::SECRET);
    }
}

Pour aller plus loin : asynchrone et idempotence

Le composant Webhook résout la partie réception sécurisée du problème. Pour la production, deux pistes complémentaires méritent d'être intégrées : l'idempotence (stocker l'identifiant de l'événement en base et le rejeter silencieusement s'il a déjà été traité) et l'asynchronisme via symfony/messenger. Le consumer peut dispatcher un message dans une queue au lieu d'agir directement — ce qui permet d'absorber les pics de charge et de retenter les traitements métier sans que Stripe ou GitHub ne le sache jamais. La combinaison Webhook Component + Messenger couvre la quasi-totalité des cas de production, et les deux composants partagent les mêmes conventions d'autowiring : migrer un consumer synchrone vers un mode asynchrone tient en quelques lignes.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.