Ibexa Commerce 5 : sécuriser un webhook Stripe et garantir l’idempotence — REX
Le paiement Stripe sandbox fonctionne déjà dans notre boutique Ibexa Commerce 5. Mais un retour navigateur réussi ne suffit pas pour construire un paiement robuste : le client peut fermer son onglet, perdre sa connexion ou revenir plusieurs fois. Stripe peut également renvoyer le même événement.
Ce chapitre ajoute donc un endpoint webhook signé et idempotent à notre projet Symfony/Ibexa. L’objectif est simple : accepter un événement Stripe authentique une seule fois et reconnaître ses rejeux sans dupliquer un traitement métier.
Le résultat obtenu
Le laboratoire fournit :
POST /webhooks/stripe;- validation de l’en-tête
Stripe-Signature; - refus des signatures invalides avec un HTTP 400 ;
- persistance minimale de l’événement ;
- clé primaire sur
event_idpour bloquer les doublons ; - réponse HTTP 200 avec le statut
duplicatelors d’un rejeu légitime ; - commande locale reproductible
app:stripe:webhook:test; - écran de supervision intégré au menu Commerce > Stripe webhooks ;
- compteur de livraisons et badge de doublon ;
- corrélation avec une commande Ibexa via
metadata.order_id; - lien direct du webhook vers la fiche de commande du back-office.
Pourquoi le retour du checkout ne suffit pas
Le retour Payum vers Ibexa dépend du navigateur. Le webhook suit un autre chemin : Stripe contacte directement notre backend.
Navigateur -> Checkout Ibexa -> Payum -> Stripe
|
Stripe -------- webhook signé -------+-> Symfony -> journal idempotent
Ces deux signaux sont complémentaires. Le retour navigateur apporte une expérience immédiate au client. Le webhook permet au backend de recevoir une information même si le navigateur disparaît.
1. Configurer le secret du webhook
Le dépôt contient uniquement une valeur factice :
STRIPE_WEBHOOK_SECRET=whsec_change_me
Avec Stripe CLI, le secret local est affiché au démarrage de l’écoute :
stripe login
stripe listen --forward-to http://127.0.0.1:8003/webhooks/stripe
Il faut copier le whsec_... obtenu dans .env.local, jamais dans Git :
STRIPE_WEBHOOK_SECRET=whsec_...
2. Vérifier cryptographiquement la requête
Le contrôleur ne fait confiance ni au JSON ni à une adresse IP. Il transmet le corps brut et l’en-tête Stripe au processeur :
#[Route('/webhooks/stripe', name: 'app_webhook_stripe', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
try {
$result = $this->processor->process(
$request->getContent(),
$request->headers->get('Stripe-Signature', ''),
);
} catch (SignatureVerificationException|UnexpectedValueException $exception) {
return new JsonResponse(['received' => false], 400);
}
return new JsonResponse(['received' => true] + $result);
}
La vérification est déléguée au SDK Stripe déjà installé par payum/stripe :
$event = Webhook::constructEvent(
$payload,
$signature,
$this->webhookSecret,
);
Le SDK vérifie la signature HMAC et la tolérance temporelle. Parser le JSON avant cette étape ou inventer sa propre signature serait une erreur.
3. Rendre le traitement idempotent
Stripe garantit un identifiant stable par événement, par exemple evt_.... Cet identifiant devient la clé primaire de notre journal :
CREATE TABLE app_stripe_webhook_event (
event_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
livemode TINYINT(1) NOT NULL,
stripe_created_at DATETIME NOT NULL,
payload_sha256 CHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
received_at DATETIME NOT NULL,
last_received_at DATETIME NOT NULL,
delivery_count INT NOT NULL DEFAULT 1,
order_identifier VARCHAR(255) DEFAULT NULL,
PRIMARY KEY(event_id)
);
Le code tente une insertion atomique. La base tranche en cas de concurrence :
try {
$this->connection->insert('app_stripe_webhook_event', [
'event_id' => $event->id,
'event_type' => $event->type,
'livemode' => $event->livemode ? 1 : 0,
'payload_sha256' => hash('sha256', $payload),
'status' => 'processed',
]);
} catch (UniqueConstraintViolationException) {
$this->connection->executeStatement(
'UPDATE app_stripe_webhook_event
SET delivery_count = delivery_count + 1,
last_received_at = :received_at
WHERE event_id = :event_id',
['received_at' => $receivedAt, 'event_id' => $event->id],
);
return ['status' => 'duplicate'];
}
Un simple SELECT suivi d’un INSERT laisserait une fenêtre de course. Deux workers pourraient ne rien trouver, puis traiter le même paiement. La contrainte unique protège même sous concurrence.
4. Ne pas stocker les données sensibles
Cette démonstration ne persiste pas le payload Stripe complet. Elle conserve seulement :
- l’identifiant et le type de l’événement ;
- le mode test ou live ;
- les dates Stripe et serveur ;
- le statut du traitement ;
- le hash SHA-256 du payload.
- l’identifiant de commande Ibexa, lorsqu’il est fourni dans les métadonnées.
Le hash permet de diagnostiquer une différence sans transformer notre base en copie des données de paiement.
5. Tester sans dépendre du réseau
La commande locale fabrique un événement sandbox, calcule une signature valide puis envoie deux fois le même contenu au processeur :
php bin/console app:stripe:webhook:test
Résultat réellement observé :
Event: evt_demo_4fc91e201116203d
First delivery: processed
Replay: duplicate
La table ne contient qu’une ligne et le hash possède bien 64 caractères :
SELECT event_id, event_type, livemode, status,
CHAR_LENGTH(payload_sha256) AS hash_length
FROM app_stripe_webhook_event;
Pour simuler un événement relié à une vraie commande Ibexa :
php bin/console app:stripe:webhook:test \
--order-id=775dd79e-c57c-4d35-abf1-67fccd904f22
La sortie rend la corrélation explicite :
Event: evt_demo_4be9496ecf5c787b
Order: 775dd79e-c57c-4d35-abf1-67fccd904f22
First delivery: processed
Replay: duplicate
Une seule ligne est conservée pour cet event_id. Le replay ne crée pas une seconde opération : il fait passer delivery_count à 2 et l’interface affiche 1 doublon.
6. Relier Stripe à la commande Ibexa
Le navigateur n’est pas une référence fiable pour corréler un paiement asynchrone. L’identifiant Ibexa doit voyager avec l’objet Stripe :
{
"metadata": {
"order_id": "775dd79e-c57c-4d35-abf1-67fccd904f22"
}
}
Après validation cryptographique, le processeur extrait uniquement cette référence :
$eventData = $event->toArray();
$orderIdentifier =
$eventData['data']['object']['metadata']['order_id'] ?? null;
Elle alimente order_identifier, puis le back-office construit le lien avec la route native Ibexa :
{{ path('ibexa.order_management.details', {
orderIdentifier: event.order_identifier
}) }}
Cette liaison répond à une question opérationnelle essentielle : quel événement Stripe concerne quelle commande Ibexa ? Elle ne suffit toutefois pas à autoriser aveuglément une transition de paiement : le montant, la devise, le mode et l’état courant doivent aussi être contrôlés.
7. Superviser les webhooks dans le back-office
La page est disponible pour les administrateurs à l’adresse :
/admin/commerce/stripe-webhooks
Elle expose quatre indicateurs :
- événements uniques ;
- livraisons reçues ;
- doublons ignorés ;
- événements live.
Chaque ligne affiche l’Event ID, la commande Ibexa liée, le type Stripe, le mode TEST/LIVE, le statut, le nombre de livraisons, les dates de première et dernière réception et une empreinte raccourcie du payload.
L’accès est protégé par le droit Ibexa d’administration :
$this->denyAccessUnlessGranted(
new Attribute('setup', 'administrate')
);
Le journal devient ainsi un outil de support : un opérateur peut identifier un rejeu, ouvrir la commande concernée et comparer les horodatages sans exposer le contenu sensible envoyé par Stripe.
8. Tester avec Stripe CLI
Une fois le serveur Symfony démarré :
symfony server:start --port=8003 --no-tls -d
stripe listen --forward-to http://127.0.0.1:8003/webhooks/stripe
Dans un second terminal :
stripe trigger charge.succeeded
Le terminal Stripe CLI doit afficher un HTTP 200. Un rejeu du même événement doit également recevoir 200 : il est authentique, mais notre backend répond duplicate et ne recommence pas le traitement.
Parcours complet depuis une base de démonstration vide
Pour comprendre la chaîne sans mélanger d’anciennes transactions :
- ouvrir le storefront et ajouter un produit au panier ;
- terminer le checkout Stripe en mode test ;
- relever l’identifiant affiché sur la fiche de commande Ibexa ;
- simuler la livraison Stripe corrélée à cette commande :
php bin/console app:stripe:webhook:test \
--order-id=IDENTIFIANT_DE_LA_COMMANDE
- ouvrir Commerce > Stripe webhooks dans le back-office ;
- vérifier
Livraisons = 2et le badge1 doublon; - cliquer sur Voir la commande.
On observe alors toute la chaîne :
Commande Ibexa
-> référence placée dans metadata.order_id
-> événement Stripe signé
-> endpoint Symfony
-> insertion atomique du premier événement
-> rejeu détecté par la clé primaire
-> compteur de livraisons incrémenté
-> lien de supervision vers la commande
Les événements créés sans --order-id restent valides pour tester la signature et l’idempotence. Le back-office indique simplement Aucune commande liée.
Pourquoi répondre 200 à un doublon ?
Le doublon n’est pas une erreur Stripe. C’est une livraison déjà prise en compte. Répondre 500 provoquerait de nouvelles tentatives inutiles. Nous répondons donc 200 tout en indiquant duplicate dans le JSON et dans les journaux applicatifs.
Ce que le webhook ne fait pas encore
Le laboratoire ne force pas directement un paiement Ibexa à Paid. Payum gère déjà le retour synchrone et une mutation parallèle sans corrélation fiable pourrait mettre à jour le mauvais paiement.
La référence Ibexa voyage désormais explicitement dans les métadonnées. L’étape de production suivante consiste à vérifier avant toute transition :
- le type d’événement attendu ;
- le mode
livemode; - la devise et le montant ;
- l’identifiant de commande ou de paiement Ibexa ;
- la transition de workflow autorisée ;
- l’idempotence de l’action métier, pas seulement celle du journal.
Checklist production
- secret webhook différent entre test et production ;
- HTTPS obligatoire ;
- signature Stripe vérifiée sur le corps brut ;
- contrainte unique en base ;
- traitement métier transactionnel ;
- logs sans données de carte ni données personnelles ;
- gestion des événements dans le désordre ;
- politique de rétention du journal ;
- métriques sur échecs et délais ;
- tests de rejeu et de concurrence ;
- remboursement et contestation traités comme événements distincts.
Fichiers ajoutés
src/Controller/StripeWebhookController.php
src/Service/StripeWebhookProcessor.php
src/Command/TestStripeWebhookCommand.php
migrations/Version20261112110000.php
migrations/Version20261112170000.php
migrations/Version20261112210000.php
src/Controller/Admin/StripeWebhookController.php
src/EventSubscriber/StripeWebhookMenuSubscriber.php
templates/admin/stripe_webhook/list.html.twig
Conclusion
Nous avons franchi une étape importante : le paiement n’est plus seulement une démonstration visuelle. Le projet possède maintenant une entrée serveur authentifiée et une défense concrète contre les rejeux.
Le principe à retenir est simple : Stripe peut livrer plusieurs fois, mais notre métier ne doit agir qu’une seule fois. La base de données, grâce à une contrainte unique, reste le dernier arbitre de cette garantie.
