18 mai 2026 · 9 min de lecture
Webhooks en production : idempotence, retries et erreurs réelles
Un guide concret pour concevoir des webhooks fiables en production : source de vérité serveur, idempotence, événements hors ordre, observabilité et reprise manuelle.
Sommaire
Un webhook a l’air simple. Un service externe appelle une URL, le backend reçoit un événement, puis met à jour son état. Sur un schéma d’architecture, c’est une flèche propre.
En production, cette flèche est beaucoup moins propre.
Un webhook peut arriver deux fois, arriver en retard, ou ne pas arriver au moment où l’utilisateur regarde l’écran. Il peut aussi échouer parce que le serveur redémarre, parce qu’une dépendance est lente, ou parce qu’un payload ne correspond pas exactement au cas prévu.
Le problème n’est pas le webhook en lui-même. Le problème, c’est de le traiter comme un simple callback alors qu’il pilote souvent une décision métier importante : valider un paiement, générer un billet, activer un abonnement, envoyer une notification, synchroniser un statut.
Le serveur doit rester la source de vérité
Le premier piège est de faire confiance au retour utilisateur. Par exemple : l’utilisateur revient d’une page de paiement, le frontend affiche “paiement réussi”, puis l’application génère la commande.
C’est tentant, parce que l’expérience paraît immédiate. Mais ce n’est pas le bon endroit pour décider.
Le frontend peut être fermé, rafraîchi, ralenti, modifié ou désynchronisé. La page de retour peut être appelée sans que l’événement métier soit réellement confirmé. Le paiement peut rester en attente quelques secondes. Le navigateur ne doit pas devenir la source de vérité.
Le bon modèle est plus strict :
- le frontend déclenche une action ;
- le service externe traite l’opération ;
- le webhook confirme l’état côté serveur ;
- le backend met à jour le statut métier ;
- le frontend lit ce statut depuis l’API.
Dans le cas d’une billetterie, ça veut dire que le billet est généré après confirmation serveur, pas parce qu’un écran de succès s’est affiché.
Un même événement peut arriver plusieurs fois
La plupart des fournisseurs sérieux retentent les webhooks en cas d’échec. C’est une bonne chose. Mais ça veut dire qu’un même événement peut arriver plusieurs fois.
Si le handler crée une ressource à chaque réception, on obtient rapidement des doublons : deux billets, deux emails, deux activations, deux écritures comptables.
Un handler de webhook doit être idempotent. Traiter deux fois le même événement doit produire le même résultat que le traiter une seule fois.
async handlePaymentSucceeded(event: PaymentSucceededEvent) {
const existing = await this.webhookEventRepository.findByProviderId(event.id);
if (existing?.status === 'processed') {
return;
}
await this.webhookEventRepository.upsertReceivedEvent({
providerId: event.id,
type: event.type,
payload: event,
});
const order = await this.orderRepository.findByPaymentId(event.paymentId);
if (!order || order.status === 'paid') {
await this.webhookEventRepository.markAsProcessed(event.id);
return;
}
await this.orderService.confirmOrder(order.id);
await this.webhookEventRepository.markAsProcessed(event.id);
}
Stocker l’identifiant d’événement est une base simple. Ensuite, chaque action métier doit aussi vérifier l’état existant. Ce n’est pas redondant : c’est une protection contre les courses, les retries et les reprises manuelles.
Il y a quand même un détail important : un simple check applicatif ne suffit pas toujours. Si deux livraisons du même événement arrivent presque au même moment, deux workers peuvent lire “pas encore traité” puis exécuter le même traitement. En pratique, il faut souvent combiner la logique applicative avec une contrainte d’unicité en base, un verrou, ou une transaction qui rend le doublon impossible.
Réception rapide, traitement robuste
Un webhook ne devrait pas devenir un traitement long et fragile. Si le fournisseur attend une réponse HTTP rapide, faire toute la logique dans la requête peut créer des erreurs inutiles.
Selon la criticité, je préfère souvent séparer la réception et le traitement :
- vérifier la signature ;
- enregistrer l’événement brut ;
- répondre rapidement ;
- traiter via une queue ou un job ;
- marquer l’événement comme traité ou en erreur.
Ce modèle améliore fortement la reprise. Si le traitement échoue, l’événement existe encore. On peut le rejouer, diagnostiquer, ou corriger le code sans demander au fournisseur de renvoyer quoi que ce soit.
Pour un petit produit, une queue complète n’est pas toujours nécessaire au début. Mais enregistrer les événements entrants est déjà un énorme progrès par rapport à un handler qui fait tout en mémoire et perd le contexte au premier crash.
À l’inverse, je ne considère pas qu’une queue soit obligatoire dans tous les cas. Si le traitement est très court, atomique et bien borné, le faire dans la requête peut être acceptable. Le point important n’est pas de mettre une queue partout, mais d’éviter qu’un endpoint exposé dépende d’un traitement long, fragile ou impossible à rejouer.
Les statuts métier comptent plus que les statuts techniques
Un statut fournisseur ne suffit pas toujours à décrire l’état réel du produit.
payment_succeeded ne veut pas forcément dire “commande terminée”. Ça peut vouloir dire :
- paiement confirmé ;
- commande à valider ;
- billet à générer ;
- email à envoyer ;
- facture à créer ;
- stock à décrémenter.
Si tout est résumé dans un booléen paid, on perd la capacité à comprendre où le processus a bloqué.
Je préfère modéliser des états métier plus explicites :
type OrderStatus =
| 'pending_payment'
| 'payment_confirmed'
| 'tickets_generated'
| 'completed'
| 'failed';
Ce n’est pas seulement une question de code propre. C’est ce qui permet de répondre à une vraie question de support : “l’utilisateur a payé, pourquoi n’a-t-il pas son billet ?”
Avec des statuts explicites, on peut voir si le paiement est confirmé mais si la génération a échoué. Sans ça, on a juste une commande “bizarre”.
Sans observabilité, le support devine
Un webhook qui échoue silencieusement est un problème très pénible. L’utilisateur voit une conséquence, mais l’équipe ne voit pas la cause.
Je veux au minimum :
- un log structuré avec l’identifiant de l’événement ;
- le type d’événement ;
- le statut de traitement ;
- l’identifiant métier concerné ;
- la raison de l’échec si le traitement échoue.
Idéalement, je veux aussi une petite vue interne ou une commande admin pour lister les événements en erreur et les rejouer.
this.logger.warn('Webhook processing failed', {
provider: 'stripe',
eventId: event.id,
eventType: event.type,
orderId: order?.id,
error: error.message,
});
Ce genre de log paraît banal. En production, il fait gagner énormément de temps. Les webhooks sont asynchrones par nature ; sans trace, on finit par deviner.
La reprise manuelle fait partie du design
On veut tous des systèmes automatiques. Mais sur les flux importants, il faut prévoir une porte de sortie.
Un paiement confirmé avec une génération de billet échouée ne doit pas demander une intervention en base de données à la main. Il faut pouvoir relancer le traitement, régénérer les ressources, ou marquer un événement comme traité après correction.
Concrètement, un cas banal ressemble à ça : le paiement est confirmé, la commande passe en payment_confirmed, puis l’envoi d’email échoue à cause d’un provider temporairement indisponible. Si l’événement est stocké avec un statut failed, une commande du type replay-webhook evt_123 ou une action d’admin permet de rejouer proprement le traitement sans bricoler la base.
La reprise manuelle peut être très simple au début :
- une commande CLI ;
- un endpoint admin protégé ;
- un script qui rejoue les événements en erreur ;
- une action dans un backoffice interne.
L’important est de l’assumer dans le design. Si le seul plan de reprise est “on ira modifier PostgreSQL à la main”, le système n’est pas vraiment prêt.
La signature n’est pas optionnelle
Un webhook est une porte HTTP exposée. Il faut vérifier que l’événement vient bien du fournisseur attendu.
La plupart des services proposent une signature basée sur le payload brut et un secret. Il faut l’utiliser. Et il faut faire attention au détail du payload brut : certains frameworks parsers JSON modifient le corps avant vérification, ce qui peut casser la validation.
Le handler doit refuser ce qui n’est pas signé correctement. Même sur un petit projet. Surtout si le webhook déclenche des actions sensibles comme un paiement, une activation ou une génération de droit d’accès.
La sécurité ne doit pas dépendre du fait que “personne ne connaît l’URL”.
Les événements n’arrivent pas toujours dans l’ordre
Recevoir le bon événement ne garantit pas de le recevoir au bon moment.
Selon le fournisseur et le type d’intégration, un événement peut arriver avant qu’une donnée locale existe, ou dans un ordre différent de celui qu’on imaginait en lisant la documentation. Un updated peut arriver avant un completed. Un échec peut aussi être reçu alors qu’un état intermédiaire n’a pas encore été persisté localement.
Ça veut dire qu’un handler robuste ne raisonne pas seulement en “quel événement je viens de recevoir ?”, mais aussi en “dans quel état local suis-je autorisé à l’appliquer ?”. Si la commande n’existe pas encore, il faut parfois différer le traitement, stocker l’événement pour reprise, ou ignorer provisoirement avec une trace explicite.
Le succès seul ne teste rien
Tester un webhook avec un seul événement de succès ne suffit pas.
Les scénarios utiles sont moins confortables :
- événement reçu deux fois ;
- événements reçus dans le désordre ;
- événement reçu avant que la commande locale existe ;
- événement avec paiement échoué ;
- traitement interrompu au milieu ;
- email impossible à envoyer ;
- génération de ressource en erreur ;
- retry après correction ;
- signature invalide.
Ce sont ces cas qui révèlent si l’architecture tient. Le chemin nominal dit seulement que l’intégration marche quand tout va bien.
La règle que je garde
Un webhook en production doit être traité comme une entrée métier asynchrone, pas comme une route HTTP classique.
Je garde quelques règles simples :
- le serveur décide, pas le frontend ;
- chaque événement entrant est identifiable ;
- le traitement est idempotent ;
- les statuts métier sont explicites ;
- les erreurs sont visibles ;
- la reprise est possible ;
- la signature est vérifiée.
Ce n’est pas forcément beaucoup de code. Mais c’est une différence énorme entre une intégration qui marche en démo et une intégration qui tient quand utilisateurs, retries et cas bizarres arrivent en même temps.
Si tu veux un exemple plus orienté paiement Stripe et billetterie, j’ai détaillé ce point dans mon retour d’expérience sur Stripe Connect dans Festiv, avec les impacts côté onboarding, billets, commissions et statuts produit.