Fait partie du guide Lancer un SaaS : les décisions qu'on ne reprend pas sans payer
Ce qui casse quand Stripe passe en production

Le 27 mai 2026, le jour où j’ai basculé le billing d’asap.cool en live, j’ai poussé trois patchs Stripe dans la même journée. Le premier parce que Stripe avait déplacé current_period_start et current_period_end dans items.data[], et que mon parseur serde plantait sur chaque événement. Le deuxième parce que je répondais 500 à un client inconnu, et que Stripe te rejoue l’événement pendant trois jours quand tu fais ça. Le dernier, create_preview, était le moins grave.
Le tunnel marchait pourtant depuis des semaines en test, stripe listen relayait, la 4242 passait. En gros, le mode test vérifie que ton code sait parler à Stripe. Il ne vérifie pas ce que ton code fait quand Stripe lui parle d’une façon que tu n’avais pas prévue, et en live, c’est le cas dès la première heure.
Petite précision avant de commencer : Stripe ne date pas sa doc. Tout ce que je cite ici, je l’ai lu sur la version d’API 2026-08-26.dahlia, le 14 septembre 2026, et si tu lis ça dans six mois, revérifie. Je pars aussi du principe que tu as déjà choisi Stripe ; si tu hésites encore, l’arbitrage est dans choisir ses prestataires. Le billing, c’est une des briques que je détaille dans lancer un SaaS, et c’est la seule qui touche de l’argent réel dès le premier jour.
Le webhook, jamais la page de succès
La première question que je me suis posée en branchant le billing, c’est à quel signal j’ai le droit de donner l’accès. Pas « quand le paiement a l’air passé », mais quel événement précis me donne le droit d’ouvrir le produit. Et sur ce point la doc ne laisse pas de marge : tu ne peux pas te fier à ta page de succès, parce qu’un client peut payer puis perdre sa connexion avant qu’elle ne charge. Pour les abonnements, le webhook est obligatoire.
Le schéma que Stripe décrit, ce n’est pas webhook ou redirection, c’est les deux, qui appellent la même fonction de fulfillment. La redirection sert au client resté devant son écran ; le webhook garantit que le traitement a lieu pour tous les autres. Cette fonction, telle que la doc la décrit, doit :
- supporter d’être appelée plusieurs fois avec le même identifiant de session ;
- récupérer la session depuis l’API, avec
line_itemsétendu, au lieu de faire confiance au payload reçu ; - vérifier
payment_statusavant de décider quoi que ce soit ; - exécuter le traitement, puis enregistrer l’état de ce traitement pour cette session.
Celui qui se saute le plus facilement, c’est le deuxième. Le webhook te dit qu’il s’est passé quelque chose, pas ce que Stripe tient pour vrai maintenant. Du coup, avant d’ouvrir un accès, moi je relis l’objet depuis l’API.
C’est là que mon patch ack-200 du 27 mai prend son sens. Un événement est arrivé pour un customer que ma base ne connaissait pas, mon handler a levé, Axum a répondu 500. Pour Stripe, un non-2xx est un échec de livraison, donc relance avec back-off, donc le même événement en boucle dans mes logs. Le correctif tient en une ligne : un customer inconnu, je le logue et je réponds 200. Le webhook doit être conçu pour encaisser des inconnus sans paniquer.
Le webhook qui répondait 200 et n’écrivait rien
Le même jour, un problème inverse, et plus vicieux. asap.cool tourne avec 105 tables en FORCE ROW LEVEL SECURITY, et l’application se connecte avec un rôle NOBYPASSRLS. Un webhook Stripe, par définition, arrive sans contexte d’organisation : personne n’est connecté, il n’y a pas de session. Sans policy SELECT pour ce mode « pas de contexte », Postgres renvoyait zéro ligne, sans erreur.
Le handler cherchait l’organisation liée au customer, ne trouvait rien, considérait qu’il n’y avait rien à faire, et répondait 200. Stripe était content, mes logs étaient propres, et l’abonnement n’était jamais persisté. Pour faire simple, la RLS faisait exactement ce qu’on lui demandait : ne rien montrer à quelqu’un qui n’a pas de contexte.
Répondre 200 et avoir traité, ce sont deux choses différentes.
La solution que j’ai retenue n’est pas un bypass. Un rôle qui contourne la RLS pour le webhook, c’est un rôle qui contourne la RLS pour tout ce que le webhook touchera un jour. J’ai écrit une policy « système » minuscule, qui n’autorise que la lecture nécessaire au rattachement customer → organisation, et j’ai repris ce motif à chaque fois qu’un traitement sans utilisateur a eu besoin de lire la base.
Sur SimplyJury, la RLS m’a piégé dans l’autre sens. Le webhook écrivait dans une table protégée par RLS, sans session, et ça marchait. Ça marchait parce que le rôle de connexion était propriétaire de la table, donc exempté de la RLS par défaut. Le jour où ce rôle serait remplacé par un rôle applicatif sans BYPASSRLS, le webhook aurait cessé d’écrire, avec un 200 en face.
Sur le même projet, validateEnv() faisait process.exit(1) quand un price ID manquait. Merger sans les IDs mettait le site hors ligne, et dans une fonction serverless un exit ne laisse aucun message. J’ai remplacé la sortie par une exception levée, pour que la route casse au lieu du site.
Ton framework a déjà cassé ta signature
Après la mauvaise clé secrète, c’est la cause d’échec que Stripe documente : le secret est bon, la vérification échoue quand même, parce que le framework a réécrit le corps de la requête avant que tu le lises.
constructEvent prend le corps brut, le header Stripe-Signature et le secret whsec_ de l’endpoint. Le trajet à protéger est court :
- Stripe envoie la requête, signée sur les octets exacts du corps.
- Le corps doit arriver à ton handler sans avoir été parsé, réencodé ni reformaté.
constructEventrecalcule la signature et la compare au header.- Seulement là, l’événement devient une information de confiance.
Tout middleware qui parse le JSON avant l’étape deux casse la chaîne. Stripe nomme Express avec express.json() déclaré avant la route webhook, Next.js en App Router comme en Pages Router, et API Gateway devant Lambda, qui demande un mapping pour exposer le corps brut.
Le deuxième piège, c’est l’environnement. Chaque endpoint a son secret, différent entre test et live même pour la même URL, et celui que stripe listen te donne en local est encore un autre. Quand tu fais tourner un secret depuis le Dashboard, l’ancien peut rester actif jusqu’à 24 heures, et Stripe signe alors avec chacun des secrets actifs.
Reste la tolérance temporelle. Les bibliothèques officielles acceptent cinq minutes d’écart entre le timestamp signé et l’heure du serveur, et la doc précise qu’une tolérance à zéro désactive le contrôle. Si tes signatures valides échouent par vagues, regarde ton NTP avant ton code.
Tout arrive deux fois, dans le désordre, et Checkout n’attend pas ton avis
Mon parseur serde du 27 mai plantait parce qu’il exigeait current_period_end à la racine de l’abonnement, et qu’une version majeure l’avait déplacé. Ça, aucune tolérance ne l’aurait rattrapé : un champ déplacé est un champ manquant, et la seule parade est de lire les notes de version quand tu changes de majeure. Le reste de l’année, c’est l’inverse : Stripe ajoute des champs et des types d’événements à chaque version mensuelle, sans casser les anciennes, et un handler qui refuse ce qu’il ne connaît pas est une panne programmée.
Alors moi, aujourd’hui, je ne désérialise que les champs dont j’ai besoin, je tolère tout le reste, et la branche par défaut de mon match répond 200 en loguant le type. Un événement inconnu, je le note, je ne le refuse pas.
Le désordre est l’autre chose que le mode test ne montre pas : Stripe ne garantit pas que les événements arrivent dans l’ordre où ils ont été générés : la création d’un abonnement en émet plusieurs, et tu peux recevoir invoice.paid avant customer.subscription.created. Le champ created ne t’aidera pas à les remettre en ordre, il est en secondes et deux événements peuvent le partager. Et dans certains cas Stripe émet deux objets Event distincts pour le même fait, donc dédupliquer par ID d’événement ne suffit pas : la doc conseille la paire data.object.id et event.type.
Le temps de réponse compte aussi, et pour une raison qui n’a rien à voir avec ton backend. La page webhooks te demande de répondre 2xx avant toute logique complexe, parce qu’un timeout est un échec de livraison et relance le cycle de rejeu. Mais la page fulfillment précise que si ton endpoint écoute checkout.session.completed et qu’une success_url est définie, Checkout attend jusqu’à 10 secondes ta réponse avant de rediriger le client. Un handler lent ne dégrade plus ton backend, il dégrade le tunnel d’achat.
Sur asap.cool, le handler fait le minimum en synchrone : customer.subscription.updated, invoice.paid et invoice.payment_failed sont des upserts de lignes, et tout ce qui a des effets de bord (emails, relances, mises à jour dérivées) part dans une file pgmq pour le worker. La réponse 200 ne dépend jamais d’un service tiers.
La clé d’idempotence qui expire, et l’autre idempotence
Il y a deux idempotences dans une intégration Stripe, et la doc les traite sur deux pages qui ne se citent pas. La première est sortante, c’est le header Idempotency-Key sur tes appels vers l’API. Elle est acceptée sur toutes les requêtes POST, pas seulement sur les PaymentIntents. Stripe mémorise le code et le corps de la première réponse, erreur 500 comprise, et rejoue ce résultat aux appels suivants. Même clé avec des paramètres différents, tu obtiens une erreur.
Le détail qui compte en prod, c’est que ces clés sont purgées après au moins 24 heures. Passé ce délai, la même clé crée un nouvel objet. Une file de retry qui rejoue un job le surlendemain, un dead-letter repris le lundi matin : la clé est toujours là, toujours correcte, et elle ne protège plus rien. Au-delà d’une journée, la déduplication doit vivre dans ta base.
La deuxième idempotence est entrante, et c’est ton handler qui la porte. La page fulfillment dit que ta fonction peut être appelée plusieurs fois, « éventuellement en même temps », pour la même session. En même temps, ça veut dire deux requêtes qui passent ton if (déjà traité) à la même milliseconde. Le code officiel laisse un TODO à cet endroit précis.
Sur Optimo, ce point s’est réglé en une journée, le 13 mai 2026. Une première version de l’ADR billing recommandait un autre outil et écartait Stripe ; le client a tranché l’inverse le jour même, et j’ai implémenté le jour même : Checkout, Portal, et un webhook idempotent via une table StripeEvent avec une contrainte d’unicité sur l’ID d’événement. C’est la contrainte qui fait le travail, pas le if : un rejeu repart en 200 sans retraitement, et si deux copies arrivent à la même milliseconde, la seconde échoue sur l’insertion, Stripe la rejoue, et elle repart en 200 au tour suivant.
Trois semaines plus tard, début juin, j’ai ajouté une règle sur le même projet : ancrer le Customer Stripe sur l’Organization avant le premier paiement, pas après. Sinon le premier webhook arrive pour un customer que la base ne sait pas rattacher, et tu retombes sur mon 500 du 27 mai. Le détail du projet est dans la case study Optimo.
Si tu veux vérifier ton propre handler sous concurrence, la commande tient en une ligne :
# en mode test ; en live, ajoute --live -c pour passer la confirmation
for i in $(seq 1 20); do stripe events resend evt_XXX & done
Puis tu comptes les lignes créées en base. Ça doit valoir 1.
active ne veut pas dire payé
Une fois l’accès ouvert, la question devient à quel moment je le referme. La table des statuts d’abonnement a l’air de répondre : trialing et active ouvrent le produit, incomplete laisse 23 heures au client pour un premier paiement, incomplete_expired n’est pas rattrapable et demande un nouvel abonnement, past_due ne garantit aucune nouvelle tentative, et sur unpaid Stripe écrit de révoquer l’accès.
Puis la même page documente le cas qui casse le raisonnement. Avec un moyen de paiement à confirmation différée, un abonnement peut passer directement à active sans passer par incomplete, et si le paiement échoue ensuite, Stripe annule la facture mais l’abonnement reste active. Stripe ajoute de tenir compte de ce comportement dans le contrôle d’accès.
Du coup, le signal sur lequel je m’appuie n’est pas le status, c’est la facture : invoice.paid ouvre, invoice.payment_failed déclenche la suite. Le statut de l’abonnement décrit une intention de facturation, la facture décrit de l’argent.
Le dernier point, c’est que Smart Retries et dunning se règlent au Dashboard, sans code, et que c’est un réglage Dashboard qui décide du sort de l’abonnement après la dernière tentative. Une partie de ta logique d’accès vit hors de ton dépôt Git ; moi je la note dans le README du module billing, à côté du code.
L’argent n’est pas un flottant
Sur Optimo, en plein sprint, une confusion entre un coût fournisseur et un prix client m’a coûté un commit de démêlage. Le calcul avait l’air bon, les deux nombres se ressemblaient, et un montant a pris la place de l’autre. Ce genre d’erreur se voit tard, parce que rien ne plante : un montant faux est un montant comme les autres.
Côté Stripe, tous les montants sont exprimés dans l’unité mineure, en entier. 1000 pour 10 EUR, 10 pour 10 JPY. Un flottant n’est pas un choix de style, c’est un format que l’API refuse, et la conversion depuis un flottant introduit des arrondis sur de l’argent réel. Sur asap.cool, toutes les colonnes de montant s’appellent *_cents et sont des BIGINT : le nom rappelle l’unité à chaque requête.
Les cas tordus existent et ils sont documentés. ISK et UGX sont devenues des devises à zéro décimale mais se représentent encore sur deux décimales, avec 00 en partie décimale. HUF et TWD se facturent à deux décimales mais ne se virent qu’en montants entiers : un solde de 10,45 HUF ne peut pas partir en totalité, 10 HUF partent et les 0,45 restent. Le minimum pour l’euro est de 0,50 EUR, et le code ISO se saisit en minuscules.
Les proratas suivent la même logique. Stripe proratise à la seconde par défaut, et les résultats négatifs ne sont pas remboursés automatiquement, les positifs pas facturés immédiatement. Un upgrade en milieu de cycle n’encaisse rien de lui-même ; ton écran ne doit pas afficher « débité aujourd’hui » tant que la facture n’est pas payée.
Pour traquer les flottants dans un code de facturation, je lance ça avant chaque mise en prod :
grep -rnE "(amount|price|total).*(parseFloat|Number\(|toFixed|\* *100)"
Puis je regarde le type des colonnes en base. Entier, jamais float.
Ce que Stripe ne fait pas pour toi : la facture française
Si tu vends depuis la France, la facture Stripe n’est pas ta facture. Stripe Invoicing crée des enregistrements et expose les données par l’API et les webhooks, mais ne génère ni ne transmet de fichier de facture électronique conforme. Les deux voies proposées sont une app du Marketplace ou une intégration maison sur invoice.created. Avec invoice.finalized c’est possible aussi, mais une facture finalisée n’est plus modifiable : si la transmission est rejetée, il n’y a plus rien à corriger. Et il n’y a pas de champ PEPPOL natif, c’est du metadata sur le Customer.
Même logique pour la TVA avec Stripe Tax, qui n’est pas le Managed Payments : il calcule et collecte, mais la déclaration et le reversement restent à ta charge, et Stripe ne vérifie pas la validité du numéro de TVA que ton client B2B te donne. La vérification VIES est de ton côté.
Sur asap.cool, c’est le cœur du produit, et c’est aussi là que j’ai eu le silence le plus long. Le 19 juillet 2026, j’ai découvert que gate_conformance, la fonction qui valide une facture avant scellement, renvoyait Ok(()) quand le validateur n’était pas configuré. Et il était exclu de la prod depuis le 28 mai. Pendant sept semaines, « on n’a pas vérifié » était indiscernable de « ça passe ». Le gate est bruyant maintenant : validateur absent, facture bloquée.
Juste avant, les 16 et 17 juillet, j’avais quitté react-pdf pour Typst, parce que produire du PDF/A-3b avec le schéma XMP Factur-X était le risque numéro un du projet. Le rendu est déterministe, gardé par des goldens byte-exacts. Ce n’est pas une intégration Stripe, c’est une partie du produit, et c’est le genre de brique que je construis dans un SaaS sur-mesure et que j’exploite moi-même sur asap.cool.
Le = en trop
Le 28 mai, le lendemain des patchs, toutes les routes billing d’asap.cool sont passées en 503, et elles y sont restées toute la journée. Stripe répondait 401 à chaque appel. La clé live faisait 108 caractères au lieu de 107 : un = en trop, collé en fin de valeur au moment de poser le secret. Tous les contrôles de la veille étaient bons, le webhook était idempotent, la signature vérifiée, et un caractère suffisait.
Je l’ai trouvé en comptant les caractères de la clé, puis en vérifiant la valeur corrigée avec un simple GET /v1/balance, qui a enfin répondu 200. C’est le contrôle le moins cher qui existe, et il n’était pas dans ma checklist de la veille.
Ce que je fais sur asap.cool, aujourd’hui, tient en peu de règles. Le webhook pose un SETNX stripe:event:<id> dans Redis avec 24 heures de vie, et un doublon repart en 200 sans être retraité ; chaque écriture derrière est en ON CONFLICT, pour le jour où Redis ne répond pas. Il répond 200 à tout ce qu’il ne connaît pas, customer inconnu ou type inconnu. Les traitements sans utilisateur passent par une policy système minuscule, jamais par un rôle qui contourne la RLS. Les montants sont en centimes partout, de la base à l’écran. Et une facture ne sort que si le validateur a répondu.
Si tu veux la suite de ce genre de retours, incidents compris, c’est ce que j’envoie dans Sycode Dispatch.