Un utilisateur annule son abonnement et reste pourtant actif dans votre système
Ce scénario existe, il est plus fréquent qu'on ne le croit, et il ne produit aucune erreur visible dans vos logs. Un client souscrit, paie, puis annule dans les secondes qui suivent. Votre prestataire de paiement envoie deux notifications distinctes : « paiement réussi », puis « abonnement annulé ». Mais votre serveur les reçoit dans l'ordre inverse. Résultat : l'utilisateur est d'abord marqué inactif, puis repassé actif par la notification tardive. Il accède à votre service sans payer.
Cet article s'appuie sur une analyse publiée par Elias Alrgeai sur dev.to et explore les causes, les pièges classiques, et les solutions concrètes en PHP/Symfony.
Pourquoi les webhooks arrivent-ils dans le désordre ?
Un webhook est une simple requête HTTP envoyée par un service tiers (Stripe, Mollie, PayPlug...) vers votre serveur pour signaler un événement. Le problème : chaque notification voyage de façon indépendante, sans coordination avec les autres. Plusieurs facteurs peuvent provoquer un ordre d'arrivée inattendu :
- une congestion réseau momentanée sur l'une des requêtes
- une charge serveur élevée au moment de la réception
- un mécanisme de retry : si votre serveur ne répond pas assez vite au premier webhook, le prestataire le renvoie plus tard, après que le second soit déjà arrivé
Ce n'est pas un bug du prestataire de paiement. C'est le fonctionnement normal d'un système asynchrone distribué. La question n'est donc pas de l'empêcher, mais de concevoir un traitement qui reste correct quelle que soit l'ordre d'arrivée.
L'implémentation naïve et ses conséquences
Une approche courante consiste à mettre à jour la base de données dès réception de chaque webhook :
public function handleWebhook(Request $request): void
{
$event = $request->input('type');
$userId = $request->input('user_id');
if ($event === 'payment_succeeded') {
User::find($userId)->update(['active' => true]);
}
if ($event === 'subscription_cancelled') {
User::find($userId)->update(['active' => false]);
}
}
Cette logique suppose implicitement que les événements arrivent dans l'ordre chronologique. Or, si subscription_cancelled arrive en premier, l'utilisateur est désactivé, puis payment_succeeded arrive et le réactive. La base de données reflète un état faux par rapport à la réalité du paiement.
Le problème est structurel : on applique un état sans vérifier s'il est plus récent que celui déjà enregistré.
Deux approches pour corriger la race condition
1. Comparer les timestamps d'événements
Chaque événement Stripe contient un champ created qui indique l'horodatage réel de l'événement côté prestataire. En stockant ce timestamp en base, vous pouvez refuser d'appliquer un événement plus ancien que le dernier traité :
public function handleWebhook(Request $request): void
{
$event = $request->input('type');
$userId = $request->input('user_id');
$eventTime = $request->input('created'); // timestamp Unix fourni par Stripe
$user = User::find($userId);
if ($user->last_webhook_at >= $eventTime) {
// Événement plus ancien que le dernier traité : on l'ignore
return;
}
if ($event === 'payment_succeeded') {
$user->update(['active' => true, 'last_webhook_at' => $eventTime]);
}
if ($event === 'subscription_cancelled') {
$user->update(['active' => false, 'last_webhook_at' => $eventTime]);
}
}
Cette approche est simple et couvre la majorité des cas. Elle suppose que les timestamps fournis par le prestataire sont fiables, ce qui est généralement le cas pour Stripe.
2. Vérifier l'état réel auprès de l'API du prestataire
Plutôt que de se fier aux données embarquées dans le webhook, vous interrogez directement l'API de votre prestataire au moment du traitement. Vous obtenez ainsi l'état actuel de l'abonnement, indépendamment de l'ordre d'arrivée des notifications :
public function handleWebhook(Request $request): void
{
$userId = $request->input('user_id');
$subscriptionId = $request->input('subscription_id');
// Appel API vers Stripe pour lire l'état réel
$subscription = $this->stripeClient->subscriptions->retrieve($subscriptionId);
$isActive = in_array($subscription->status, ['active', 'trialing']);
User::find($userId)->update(['active' => $isActive]);
}
Cette approche est plus robuste : peu importe quel webhook arrive en premier, vous écrasez le statut avec la vérité lue à la source. En contrepartie, elle ajoute un appel réseau supplémentaire et peut poser des limites de débit si votre volume d'événements est élevé.
Les deux stratégies sont complémentaires : la vérification par timestamp est rapide et sans effet de bord, la vérification par API est définitive mais plus coûteuse. Pour un système critique, l'idéal est de combiner les deux.
Bonnes pratiques complémentaires
Corriger la race condition ne suffit pas si le reste du pipeline reste fragile. Quelques points à vérifier dans votre implémentation :
- Répondre rapidement à chaque webhook : renvoyez un HTTP 200 immédiatement, puis traitez l'événement en file d'attente (Symfony Messenger, Laravel Queues). Un traitement synchrone trop lent déclenche des retries qui aggravent le désordre.
- Vérifier la signature du webhook : Stripe et la plupart des prestataires signent chaque notification. Valider cette signature avant tout traitement élimine les requêtes forgées.
- Rendre le traitement idempotent : si le même événement est reçu deux fois (ce qui arrive avec les retries), le résultat doit être identique. Stocker l'identifiant d'événement et ignorer les doublons est une protection fiable.
- Logger les événements reçus : conserver une trace horodatée de chaque webhook facilite le débogage en production et permet de rejouer des événements en cas d'incident.
Ce que ça change pour vous
Si votre entreprise propose des abonnements en ligne, ce type de faille peut permettre à des utilisateurs d'accéder à votre service après avoir annulé leur paiement. Aucun message d'erreur n'apparaît : tout semble fonctionner normalement. La perte est silencieuse et difficile à détecter sans audit technique.
La correction ne nécessite pas de refonte complète. Elle consiste à vérifier l'état réel de l'abonnement auprès de votre prestataire de paiement plutôt que de se fier à l'ordre d'arrivée des notifications automatiques. Demandez à votre prestataire technique de confirmer que votre plateforme applique ce type de vérification sur tous les événements de paiement sensibles.
Et votre site, il vaut quoi ?
Vitesse, accessibilité, référencement technique. Le rapport est écrit pour un dirigeant, pas pour un développeur. Gratuit, sans inscription.