Tutoriel · Stripe · Symfony

Recevoir les webhooks Stripe dans Symfony sans en perdre

Vérification de signature, réponse immédiate, dédoublonnage par identifiant d’événement, traitement asynchrone avec Messenger et relecture de l’objet à jour face au désordre.

Ce que Stripe garantit, et ce qu’il ne garantit pas

Dans le middleware Stripe–Salesforce, tout ce que Stripe sait d’un paiement arrive par webhook : un appel HTTP POST signé vers une URL du middleware. Trois propriétés de ce canal dictent la conception :

  • Au moins une fois : tant que l’URL ne répond pas 2xx, Stripe renvoie l’événement, plusieurs jours durant en mode live. Un même événement peut donc arriver deux fois.
  • Sans ordre garanti : invoice.paid peut arriver avant invoice.created, une mise à jour d’abonnement avant sa création.
  • Avec un délai de réponse court : un traitement lent est vu comme un échec, et l’événement repart.

D’où la règle : vérifier, enregistrer, répondre dans le contrôleur ; tout le reste dans une file.

Un contrôleur minimal

N’importe qui peut envoyer un POST sur l’URL du webhook. La signature de l’en-tête Stripe-Signature, calculée avec le secret propre à l’endpoint, est la seule preuve que l’appel vient de Stripe. Elle inclut un horodatage : le SDK refuse par défaut un message de plus de cinq minutes, ce qui empêche de rejouer une requête interceptée.

<?php

namespace App\Controller;

use App\Message\StripeEventReceived;
use App\Stripe\ProcessedEvents;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

final class StripeWebhookController
{
    public function __construct(
        private readonly MessageBusInterface $bus,
        private readonly ProcessedEvents $events,
        #[Autowire('%env(STRIPE_WEBHOOK_SECRET)%')] private readonly string $secret,
    ) {
    }

    #[Route('/webhooks/stripe', methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        try {
            // Le corps brut, octet pour octet : la signature porte dessus, pas sur un JSON re-sérialisé.
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->headers->get('Stripe-Signature', ''),
                $this->secret,
            );
        } catch (SignatureVerificationException|\UnexpectedValueException) {
            return new Response('', Response::HTTP_BAD_REQUEST);
        }

        // Déjà reçu (Stripe renvoie un événement non acquitté) : on confirme sans rien refaire.
        $this->events->receiveOnce($event->id, $event->type, fn () => $this->bus->dispatch(
            new StripeEventReceived($event->id, $event->type, $event->data->object->id),
        ));

        // Répondre vite : au-delà de quelques secondes, Stripe considère l'envoi comme échoué.
        return new Response('', Response::HTTP_OK);
    }
}

La route reste hors de tout pare-feu d’authentification Symfony et hors protection CSRF : la signature tient ce rôle.

Dédoublonner les événements

Chaque événement a un identifiant unique evt_…. Une table locale avec une clé primaire sur cet identifiant suffit : la base refuse le second enregistrement, même si deux livraisons arrivent à la même milliseconde.

-- Script versionné : la clé primaire fait le dédoublonnage.
CREATE TABLE stripe_event (
    id          VARCHAR(255) NOT NULL PRIMARY KEY,   -- evt_...
    type        VARCHAR(100) NOT NULL,
    received_at DATETIME     NOT NULL,
    processed_at DATETIME    NULL
);
<?php

namespace App\Stripe;

use Doctrine\DBAL\Connection;

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

    /** Exécute $then une seule fois par événement, même si Stripe le livre plusieurs fois. */
    public function receiveOnce(string $id, string $type, callable $then): void
    {
        // Même transaction que l'envoi dans la file (transport Doctrine, même connexion) : si la publication
        // échoue, la ligne disparaît aussi et la prochaine livraison de Stripe sera traitée.
        $this->db->transactional(function (Connection $db) use ($id, $type, $then): void {
            // INSERT IGNORE : deux livraisons simultanées du même événement, une seule ligne insérée.
            $stmt = $db->prepare('INSERT IGNORE INTO stripe_event (id, type, received_at) VALUES (:id, :type, NOW())');
            $stmt->bindValue('id', $id);
            $stmt->bindValue('type', $type);
            if ($stmt->executeStatement() === 1) {
                $then();
            }
        });
    }

    public function markProcessed(string $id): void
    {
        $stmt = $this->db->prepare('UPDATE stripe_event SET processed_at = NOW() WHERE id = :id');
        $stmt->bindValue('id', $id);
        $stmt->executeStatement();
    }
}

Le dédoublonnage est un filet, pas une garantie : les écritures dans Salesforce restent des upserts sur identifiant Stripe, inoffensifs s’ils sont rejoués.

Traiter dans une file

Le message ne transporte que des identifiants. Le handler aiguille selon le type d’événement :

<?php

namespace App\Message;

// L'identifiant suffit : le handler relira l'objet à jour chez Stripe.
final readonly class StripeEventReceived
{
    public function __construct(
        public string $eventId,
        public string $type,
        public string $objectId,
    ) {
    }
}
<?php

namespace App\MessageHandler;

use App\Message\StripeEventReceived;
use App\Stripe\ProcessedEvents;
use App\Sync\InvoiceSync;
use App\Sync\SubscriptionSync;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class StripeEventHandler
{
    public function __construct(
        private readonly SubscriptionSync $subscriptions,
        private readonly InvoiceSync $invoices,
        private readonly ProcessedEvents $events,
    ) {
    }

    public function __invoke(StripeEventReceived $message): void
    {
        match (true) {
            str_starts_with($message->type, 'customer.subscription.') => $this->subscriptions->sync($message->objectId),
            str_starts_with($message->type, 'invoice.') => $this->invoices->sync($message->objectId),
            default => null, // type non abonné par erreur : ignoré, mais tracé comme reçu
        };

        $this->events->markProcessed($message->eventId);
    }
}

Un transport dédié, avec des reprises longues : une maintenance Salesforce de quelques heures ne doit pas envoyer les événements de paiement au transport d’échec. Les mécanismes de reprise sont détaillés dans l’article Synchroniser vers Salesforce avec Symfony Messenger.

# config/packages/messenger.yaml (extrait)
framework:
    messenger:
        transports:
            stripe:
                dsn: 'doctrine://default?queue_name=stripe'
                retry_strategy: { max_retries: 6, delay: 60000, multiplier: 3, max_delay: 21600000 }
        routing:
            App\Message\StripeEventReceived: stripe

Le désordre : relire l’objet à jour

Écrire dans Salesforce l’objet joint à l’événement est tentant, mais dangereux : si customer.subscription.updated (passage à past_due) est traité après un événement plus récent (retour à active), Salesforce affiche un impayé qui n’existe plus.

La parade la plus simple : traiter l’événement comme une notification (« cet abonnement a changé ») et relire l’objet chez Stripe. L’état écrit est alors toujours le dernier, quel que soit l’ordre d’arrivée.

public function sync(string $subscriptionId): void
{
    // Jamais l'objet contenu dans l'événement : il peut dater d'avant un événement déjà traité.
    $subscription = $this->stripe->subscriptions->retrieve($subscriptionId);

    $this->salesforce->upsertAbonnement($subscription); // l'état écrit est toujours le plus récent
}

Coût : un appel à l’API Stripe par événement, largement sous les limites de débit pour un volume de gestion courant.

Les événements à écouter

Un endpoint ne reçoit que les types cochés dans sa configuration. S’abonner à tout surcharge la file pour rien ; quelques types couvrent un usage paiement et abonnement :

ÉvénementEffet dans Salesforce
checkout.session.completedLe paiement initial ou la souscription est confirmé
customer.subscription.created, .updated, .deletedCréation, changement de statut ou de formule, résiliation
invoice.paidÉchéance encaissée
invoice.payment_failedÉchec de prélèvement : alerte au conseiller
charge.refunded, charge.dispute.createdRemboursement, contestation

Tester en local

La Stripe CLI relaie les événements du compte de test vers le poste de développement, avec un secret de signature temporaire :

# Relayer les événements du compte de test vers le poste local
stripe listen --forward-to localhost:8000/webhooks/stripe
# → Ready! Your webhook signing secret is whsec_...   (à mettre dans .env.local)

# Déclencher un scénario complet : client, abonnement, facture, paiement
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

# Rejouer un événement précis
stripe events resend evt_...

Pour les tests automatisés, Webhook::constructEvent accepte une charge signée en test avec un secret fictif : on vérifie que le contrôleur refuse une mauvaise signature et qu’un doublon ne publie qu’un message.

Bonnes pratiques

  • Un secret par endpoint et par environnement ; la rotation se fait depuis le tableau de bord, avec une période de recouvrement.
  • Version d’API de l’endpoint : la structure des objets envoyés dépend de la version choisie à la création de l’endpoint ; l’aligner sur celle du SDK.
  • Surveillance : le tableau de bord Stripe liste les livraisons en échec ; un endpoint qui échoue trop longtemps est désactivé, d’où l’intérêt du rapprochement.
  • Journal sans données sensibles : tracer l’identifiant et le type d’événement, pas la charge complète qui contient e-mails et adresses.
🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.