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.paidpeut arriver avantinvoice.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: stripeLe 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énement | Effet dans Salesforce |
|---|---|
checkout.session.completed | Le paiement initial ou la souscription est confirmé |
customer.subscription.created, .updated, .deleted | Cré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.created | Remboursement, 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.