Mon webhook Stripe répondait 200 et n'écrivait rien : la RLS lisait zéro ligne
Stripe affichait 200, l'abonnement n'était pas écrit : sur asap.cool, la RLS renvoyait zéro ligne au webhook. Comment le voir, le corriger, le contrôler.
Sommaire · 5 sections
Un 200 dit que ton serveur a accepté la requête, pas que ta base a bougé. Si Stripe affiche l’événement comme livré et que rien n’apparaît en base, regarde ce que ton handler a lu avant d’écrire : avec une RLS forcée et un webhook qui arrive sans contexte d’organisation, un SELECT renvoie zéro ligne, sans erreur, et le handler conclut qu’il n’y a rien à faire.
Le 27 mai 2026, le jour où j’ai basculé le billing d’asap.cool en live, mes webhooks répondaient 200 et l’abonnement n’était jamais persisté. Mes logs étaient propres et Stripe affichait des livraisons réussies. Je raconte l’incident en deux paragraphes dans ce qui casse quand Stripe passe en production, et je le détaille ici. C’est la base qui me l’a dit : après un paiement, la table payments.subscription n’avait aucune ligne pour l’organisation.
Petite précision avant de commencer : Stripe ne date pas ses pages. Tout ce que je cite de sa doc, je l’ai lu le 26 octobre 2026, et la doc PostgreSQL en version 18.6 le même jour. Si tu lis ça dans six mois, revérifie.
Un 200 ne dit rien de ce que ton handler a fait
Pour Stripe, un événement affiché avec un statut 200 est une livraison réussie, et sa doc te demande de renvoyer ce 2xx avant toute logique complexe. Le code de statut est donc le mauvais instrument pour savoir si ton écriture a eu lieu. Voici comment je départage les causes quand un événement est « livré » et que la base n’a pas bougé :
- Le corps brut a été réécrit par ton framework. La vérification de signature échoue, et dans l’exemple de la doc le handler répond 400 : l’événement apparaît en échec, pas en 200. Si tu vois du 200, ce n’est pas ça.
- Tu réponds avant d’écrire. Le 200 part, le traitement continue ailleurs (file, tâche de fond) et échoue sans que personne le dise à Stripe. Cherche l’ID d’événement dans les logs de ce qui traite derrière.
- Le handler lit, ne trouve rien, et ne fait rien. Pas d’exception, pas de 500, donc pas de trace. C’est le cas qui m’a eu.
Ce qui départage vite, c’est le log. Sur asap.cool, un customer inconnu est loggé puis répond 200, et c’est justement ce qui rend la lecture vide difficile à distinguer d’un cas normal : le même chemin de code répond 200 dans les deux situations.
La RLS lit zéro ligne, sans erreur
asap.cool tourne avec 105 tables en FORCE ROW LEVEL SECURITY (relevé du 20 septembre 2026) et l’application se connecte avec un rôle NOBYPASSRLS. Un webhook arrive par définition sans utilisateur, donc sans contexte d’organisation. Sans policy SELECT pour ce mode « pas de contexte », Postgres ne renvoie rien. La doc le dit ainsi : les lignes pour lesquelles l’expression de la policy ne renvoie pas true ne sont pas traitées. Pas d’erreur, juste une table qui paraît vide.
Mon handler cherchait l’organisation rattachée au customer, n’en trouvait aucune, jugeait qu’il n’y avait rien à faire et répondait 200. Le même jour, j’avais décidé de répondre 200 aux customers inconnus pour arrêter le rejeu : la RLS fabriquait des customers inconnus à partir de customers parfaitement connus. Le handler loggait un avertissement, « customer absent from local DB, event ignored », et renvoyait {"ignored": true} avec un 200.
Ce que ça coûte tient en une phrase : Stripe prélève un client que ma base ne connaît pas, donc un client payant sans accès. Et comme Stripe a reçu un 200, il ne rejoue rien : en live, il retente pendant trois jours la livraison d’un événement qui échoue, or un 200 est une livraison réussie.
Tu peux le reproduire dans un psql vide, avec un rôle qui n’est ni superuser ni BYPASSRLS :
-- exécuté par un superuser
CREATE ROLE demo_owner LOGIN;
CREATE ROLE demo_app LOGIN;
GRANT CREATE, USAGE ON SCHEMA public TO demo_owner;
GRANT USAGE ON SCHEMA public TO demo_app;
SET ROLE demo_owner;
CREATE TABLE customers (stripe_customer_id text PRIMARY KEY, org_id uuid NOT NULL);
INSERT INTO customers VALUES ('cus_demo', '11111111-1111-1111-1111-111111111111');
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
CREATE POLICY isolation_org ON customers
USING (org_id = NULLIF(current_setting('app.org_id', true), '')::uuid);
GRANT SELECT ON customers TO demo_app;
-- le webhook : aucun contexte posé
SET ROLE demo_app;
SELECT org_id FROM customers WHERE stripe_customer_id = 'cus_demo';
Sur PostgreSQL 18.6, le dernier SELECT répond :
org_id
--------
(0 rows)
Une policy système minuscule, pas un rôle qui contourne
Le réflexe, c’est de donner au webhook un rôle qui contourne la RLS. Je ne le fais pas : un rôle qui contourne la RLS pour le webhook la contourne pour tout ce que le webhook touchera un jour. J’ai écrit une policy SELECT réservée au mode « pas de contexte », qui ne couvre que le rattachement customer vers organisation, et le même motif a resservi trois fois depuis pour d’autres traitements sans utilisateur. Dans le dépôt, c’est le commit 770af682, et la policy d’asap.cool tient en trois lignes :
CREATE POLICY payments_customer_system_lookup ON payments.customer
FOR SELECT
USING (NULLIF(current_setting('app.org_id', true), '') IS NULL);
Sur la table de démo, la même condition donne :
SET ROLE demo_owner;
CREATE POLICY lecture_sans_contexte ON customers FOR SELECT
USING (NULLIF(current_setting('app.org_id', true), '') IS NULL);
SET ROLE demo_app;
SELECT org_id FROM customers WHERE stripe_customer_id = 'cus_demo';
org_id
--------------------------------------
11111111-1111-1111-1111-111111111111
(1 row)
Une fois l’organisation trouvée, le handler ne fait plus rien sans contexte. Chaque lecture et chaque écriture suivante passe par OrgTx, un type qui ouvre la transaction et pose app.org_id dans son constructeur : il n’existe aucun moyen d’obtenir une connexion utilisable sans que le contexte soit déjà posé. Les écritures sur payments.customer restent donc soumises à la policy d’isolation habituelle.
Cette policy ouvre la lecture à qui n’a pas de contexte, sur cette seule table, qui ne porte que le rattachement : ce que l’ouverture expose est borné par ce que la table contient.
Compter ce que Stripe envoie contre ce que ta base écrit
Dans cet incident, rien ne clignotait : l’onglet Event deliveries de Workbench liste les événements comme Delivered, Pending ou Failed, avec le code HTTP de la tentative. Un 200 y est un succès, donc la RLS qui lisait zéro ligne n’y apparaissait pas. Le contrôle qui l’aurait vu le 27 mai, et que je mettrais en place, compare deux nombres sur la même période : les événements customer.subscription.created reçus d’un côté, les abonnements écrits de l’autre. Sur asap.cool, le second se lit ainsi :
SELECT count(*) FROM payments.subscription WHERE created_at >= now() - interval '24 hours';
La requête tourne avec un rôle d’administration, pas avec celui de l’application : sous RLS et sans contexte, elle renverrait zéro, ce qui reproduirait le bug qu’elle doit attraper. Un écart entre les deux nombres déclencherait l’alerte. Aujourd’hui, le seul signal est l’avertissement « customer absent from local DB » dans les logs, et il reste sous le seuil d’erreur qui déclenche mes alertes. Une fois la cause corrigée, tu peux rejouer les événements ratés : le bouton Resend du Dashboard marche jusqu’à 15 jours après la création de l’événement, la commande stripe events resend <event_id> --webhook-endpoint=<endpoint_id> jusqu’à 30 jours. Il faut pour ça un handler qui supporte de recevoir deux fois le même événement, ce que je traite dans dédupliquer et ordonner les événements Stripe. Les événements du 27 mai, je les ai rejoués avec le bouton Resend, une fois la policy en place.
Ce que je fais depuis
Sur asap.cool, les traitements sans utilisateur lisent la base par une policy système qui n’ouvre que la lecture dont ils ont besoin, jamais par un rôle qui contourne la RLS. Le cas n’est apparu que parce que le rôle de l’application est soumis à la RLS : un rôle exempté, propriétaire d’une table sans FORCE ou BYPASSRLS, aurait lu la ligne, et le bug serait resté caché. C’est le sujet de l’article suivant, la RLS contournée par le rôle, avec la requête qui te dit si ton rôle est dans ce cas.
Besoin d'aide sur ce sujet ?
Reprise, maintenance et mise en productionDe 350 € à 2 500 €
Réserver un appel




