Aller au contenu principal
Site en cours de refonte — quelques pages peuvent bouger ou évoluer.
23 mars 2026

Les “silent payment failures” : la fuite de MRR que tu ne vois jamais venir

Le pire avec un paiement qui échoue, c’est quand personne ne le voit. Voilà où ton MRR fuit (webhooks, statuts, entitlement) et comment instrumenter ça proprement.

13 min de lecture
32 vues
réactions
Partager :
Les “silent payment failures” : la fuite de MRR que tu ne vois jamais venir

Tu peux avoir un bon produit, un pricing qui tient la route, et pourtant perdre du MRR… en silence. Pas parce que les clients veulent partir, mais parce que ta chaîne de paiement a des trous. Un webhook perdu, un handler qui plante sans alerte, un statut « paid » qui diverge entre le provider et ta base, un entitlement qui reste bloqué. Et toi tu découvres le problème au support, ou pire, au churn.

Ce que j’appelle « silent payment failures », c’est exactement ça : des incidents de billing qui ne font pas tomber ton app, ne font pas tomber le checkout, mais cassent la vérité métier. Le playbook ci-dessous, c’est celui que j’aurais aimé avoir quand j’ai vu des SaaS “perdre” des abonnements alors que Stripe (ou un autre provider) faisait son job.

Silent payment failures : ce que c’est (et pourquoi c’est si vicieux)

Un « payment failure » visible, c’est simple : l’utilisateur voit une erreur au checkout, il réessaie, il te ping. Un « silent payment failure », c’est quand le provider a un statut clair (paiement échoué, 3DS en attente, abonnement passé en past_due, invoice impayée, carte expirée), mais que ton système interne continue comme si de rien n’était. Ou l’inverse : tu coupes l’accès alors que le provider a fini par encaisser après retry.

Le côté vicieux vient du fait que tu peux avoir des métriques « OK » au mauvais endroit. Ton API est verte, tes pages se chargent, ton cron tourne. La fuite est dans la plomberie : la livraison d’événements, la persistance, l’idempotence, la réconciliation. Ce n’est pas glamour. C’est exactement pour ça que personne ne regarde.

Cartographier la chaîne billing de bout en bout (là où ça casse vraiment)

Avant de rajouter des alertes dans tous les sens, il faut une carte. Le chemin typique ressemble à ça : ton front lance un checkout, le provider orchestre l’authent (3DS, wallet, SCA), il finalise un paiement et/ou un abonnement, puis il te pousse des webhooks, tu mets à jour ta base, et enfin tu calcules l’entitlement (accès, quota, plan, facture, période).

Le bug n’est pas toujours « le paiement a échoué ». Souvent c’est « le paiement a échoué et on n’a pas traité l’info », ou « le paiement a réussi mais notre entitlements n’a pas suivi ». Donc ta carte doit distinguer trois vérités : la vérité du provider (source of truth sur le paiement), la vérité de ta base (ce que tu crois), et la vérité produit (qui a accès à quoi, maintenant).

Mon conseil terrain : ajoute un identifiant de corrélation dès le début (checkout_session_id, payment_intent_id, subscription_id… selon ton provider) et propage-le partout. Dans tes logs applicatifs, dans la table d’événements, dans l’audit utilisateur. Quand ça part en vrille, tu veux remonter le film sans faire de l’archéologie.

Webhooks fiables : signature, idempotency, dédup… et un endroit pour mettre les ratés

Les webhooks, c’est le cœur du sujet. Et non, « on a un endpoint /webhook qui retourne 200 » n’est pas une preuve de fiabilité. Tu veux un pipeline, pas une route Express bricolée.

D’abord, la base : vérification de signature et conservation du raw body. Beaucoup de frameworks te « parsèrent » le JSON, et tu finis par vérifier une signature sur un payload modifié. Résultat : tu désactives la vérif « temporairement », puis tu oublies. Mauvaise idée. La signature est ton garde-fou numéro 1 contre les appels fantômes et les bugs de configuration.

Ensuite, l’idempotence. Un provider va retry. Ton infra va parfois rejouer. Et ton code va forcément reprocesser un événement à un moment. Donc tu stockes chaque événement reçu dans une table (ou un bucket) avec une contrainte d’unicité sur l’id d’événement du provider. Après seulement, tu déclenches le traitement métier. Tu ne veux jamais que « traiter l’événement » et « enregistrer l’événement » soient une seule opération fragile.

Enfin, il te faut un endroit pour mettre les ratés. Le monde réel, c’est des timeouts, des JSON imprévus, une DB en surcharge, une migration au mauvais moment. Si un événement ne peut pas être traité, tu le mets en DLQ (dead letter queue) ou, à défaut, dans une table « failed_webhook_jobs » avec la raison, le nombre de tentatives, et un bouton (ou une commande) pour rejouer proprement. Sinon, tu vas “réparer” à la main en base à 2h du mat.

import express from "express";
import crypto from "crypto";

const app = express();

// Important : il faut le RAW body pour vérifier la signature.
app.post("/webhooks/provider", express.raw({ type: "application/json" }), async (req, res) => {
  const sig = req.header("provider-signature") || "";
  const raw = req.body as Buffer;

  if (!verifySignature(raw, sig, process.env.WEBHOOK_SECRET!)) {
    return res.status(400).send("invalid signature");
  }

  const event = JSON.parse(raw.toString("utf8"));

  // 1) On persiste l'événement (id unique) pour dédup.
  // 2) On traite en async (queue) pour éviter le timeout et isoler les erreurs.
  try {
    await saveEventIfNew({
      providerEventId: event.id,
      type: event.type,
      payload: event,
      receivedAt: new Date()
    });
  } catch (e: any) {
    // Si l'event existe déjà, on ACK quand même.
    if (e.code === "UNIQUE_VIOLATION") return res.status(200).send("ok");
    throw e;
  }

  await enqueueWebhookJob({ providerEventId: event.id });
  return res.status(200).send("ok");
});

function verifySignature(raw: Buffer, sigHeader: string, secret: string) {
  const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}

Retries et backoff : le provider ne va pas te sauver tout seul

Oui, la plupart des providers retry les webhooks. Mais les retries ont des limites, et surtout tu ne contrôles pas la stratégie. Et toi, de ton côté, tu as aussi des appels sortants vers l’API billing (création d’abonnement, attachement d’un moyen de paiement, prorata, annulation). Ces appels-là aussi doivent être idempotents et retryables, sinon tu crées des doubles, ou tu laisses des objets dans un état bâtard.

Concrètement, côté app, je veux un traitement webhook qui supporte nativement le « at least once ». Ça veut dire : opérations idempotentes, verrous quand il faut (par subscription_id par exemple), et un retry avec backoff côté queue quand une dépendance temporaire tombe (DB, réseau, rate limit). La règle : on ne « swallow » pas une erreur en se disant que « ça repassera ». Non. Soit ça repasse parce que tu as un retry maîtrisé, soit tu le vois parce que tu as une DLQ + alerte.

Et fais attention à un piège très courant : le handler qui fait trop de choses dans la requête webhook. Tu augmentes le risque de timeout, donc tu augmentes le risque de retry, donc tu augmentes les doublons, donc tu crées un problème que tu n’avais pas. Le webhook doit être un intake robuste. Le métier doit tourner en job.

Alerting utile : alerte sur les bons signaux, pas sur « ça a planté »

Le monitoring « bête » (5xx sur /webhook) ne suffit pas. Les silent failures, c’est justement quand l’endpoint répond 200 mais que tu n’as pas fait le boulot, ou quand tu traites mais avec 6 heures de retard, ou quand un type d’événement n’est plus pris en charge après un changement de payload.

Ce que je veux voir en dashboard, c’est la santé du pipeline. Exemple concret : le délai de livraison et de traitement des webhooks (p50/p95), le taux d’échec de processing par event type, le taux de déduplication (si ça explose, tu as un souci de retries), et le volume d’événements inconnus (nouveaux types, schéma qui change, versioning implicite).

Et je veux des alertes qui déclenchent une action. Une alerte « DLQ > 0 depuis 10 minutes » est actionnable. Une alerte « webhook latency p95 > 5 min pendant 15 min » est actionnable. Une alerte « 0 événements subscription.updated depuis 24h » peut être actionnable aussi si tu sais que ton business en génère tous les jours. À l’inverse, « le job a échoué » sans contexte, ça finit ignoré.

Dernier point : loggue les transitions, pas juste les erreurs. Quand une subscription passe de active à past_due ou quand une invoice devient paid, c’est une info produit. Si tu ne peux pas répondre en 30 secondes à « pourquoi cet utilisateur a perdu l’accès ? », tu as déjà un problème.

Réconciliation provider ↔ DB : ton filet de sécurité contre l’involuntary churn

Même avec une bonne plomberie, il faut accepter la réalité : tu auras des trous. Un webhook non livré (ou livré mais non traité), un incident interne, une régression après déploiement, un schéma qui change. La réconciliation, c’est ton filet de sécurité. C’est ce qui empêche une erreur de pipeline de devenir du churn involontaire.

Le principe est simple : le provider est la source de vérité sur l’état de paiement. Ta DB est la source de vérité sur ce que ton produit sert. Donc tu exécutes régulièrement (souvent toutes les heures ou tous les jours selon le volume) un job qui compare les deux sur une fenêtre de temps pertinente. Tu listes les écarts, tu appliques des corrections, et tu génères un rapport. Et surtout, tu gardes des traces, parce qu’un backfill silencieux qui modifie des accès sans audit, c’est une autre forme d’incident.

Ce job est aussi l’endroit parfait pour faire du backfill d’événements. Si tu vois une invoice payée côté provider mais que tu n’as pas la transition interne, tu peux re-synchroniser proprement à partir des objets provider (invoice/subscription/payment intent) au lieu d’attendre un webhook qui ne viendra plus.

-- Exemple d'idée : repérer des abonnements internes "active" alors que le provider ne l'est plus.
-- Ici on suppose que tu stockes le statut provider le plus récent dans une table mirror (ou via sync).

SELECT s.id, s.user_id, s.internal_status, p.provider_status, s.updated_at
FROM subscriptions s
JOIN provider_subscriptions p ON p.provider_subscription_id = s.provider_subscription_id
WHERE s.internal_status = 'active'
  AND p.provider_status IN ('past_due', 'unpaid', 'canceled')
  AND s.updated_at < NOW() - INTERVAL '15 minutes';

Cas mobiles, Safari, 3DS : là où les checkouts “réussis” se perdent

Si tu ne vends que sur desktop Chrome, la vie est belle. Dans le monde réel, tu as Safari, des apps in-app browser, des changements d’onglet, des sessions expirées, du 3DS qui demande une validation, et des retours sur ton site qui n’arrivent jamais. Et le plus frustrant, c’est que ce n’est pas toujours un échec de paiement. C’est un échec de « fin de parcours ».

Deux réflexes qui sauvent : d’abord, ne prends jamais le retour front (success_url) comme source de vérité. C’est juste une redirection. La vérité doit venir du provider (webhook ou récupération server-side). Ensuite, instrumente le parcours : quand tu crées une session de checkout, loggue l’intention côté server, associe-la à un user_id et à un device context, et marque explicitement les états « created », « redirected », « returned », « verified provider-side ». Tu veux pouvoir distinguer « l’utilisateur n’a jamais fini » de « il a fini mais on n’a pas capté ».

Et oui, il faut gérer les cas « pending » proprement. Un paiement peut être en attente de confirmation. Un abonnement peut être créé mais incomplet. Si ton entitlement coupe ou active l’accès trop tôt, tu vas faire du bruit inutile (support, chargebacks, frustration). Le bon compromis, c’est souvent un état produit intermédiaire clair, avec un re-check automatique côté serveur tant que le provider n’a pas tranché.

Les erreurs fréquentes que je vois (et qui provoquent du churn)

La première, c’est de coder un handler webhook comme un « switch/case sur event.type » sans plan B. Le jour où un event change légèrement de payload, ton parse plante, tu catch, tu loggues au mieux… et tu réponds 200 parce que « faut pas que Stripe retry ». Résultat : tu t’auto-voles. Si tu ne peux pas traiter, tu dois le savoir et pouvoir rejouer.

La deuxième, c’est de supposer un ordre d’arrivée des événements. Dans la vraie vie, tu peux recevoir un update avant un create, ou un retry plus tard, ou des événements en double. Si ton code dépend de « l’ordre logique », tu vas créer des états impossibles. Ta DB doit accepter que l’événement X arrive deux fois et que l’événement Y arrive « trop tôt ».

La troisième, c’est l’entitlement couplé trop fort à ton modèle interne. Exemple typique : « si subscription.active = true alors accès ». C’est tentant, mais si ce booléen dépend d’un webhook unique, tu as fabriqué une bombe à retardement. L’entitlement doit être une projection robuste, recalculable, et corrigeable par réconciliation. Ce n’est pas juste un champ dans une table.

Mon avis : le billing, ce n’est pas « du back-office », c’est un système distribué

Beaucoup d’équipes traitent le paiement comme un module externe. En pratique, tu intègres un système distribué avec des événements asynchrones, du retry, des états transitoires, et des dépendances tierces qui changent. Si tu le construis comme une feature « à livrer vite », tu vas le payer plus tard. En MRR. Et en support.

La bonne nouvelle, c’est que ce n’est pas une montagne. Quand tu as une vraie ingestion d’événements, une idempotence propre, des alertes sur les bons signaux et une réconciliation régulière, tu passes d’un mode « on croise les doigts » à un mode « on sait quand ça casse, et on sait réparer ». Et ça, c’est exactement ce qui fait la différence entre un SaaS qui subit son involuntary churn et un SaaS qui le réduit vraiment.

Sources

Cet article vous a plu ?

Commentaires

Laisser un commentaire

Entre 10 et 2000 caractères

Les commentaires sont modérés avant publication.

Aucun commentaire pour le moment.

Soyez le premier à donner votre avis !