Webhooks de facturation idempotents : l'exécution exactement-une-fois avec Stripe
Les prestataires de paiement livrent leurs webhooks au moins une fois. Ce n'est pas un bug de leur côté, c'est la seule garantie qu'un réseau peut offrir : si votre endpoint est lent, expire, ou répond 500 après avoir validé, l'événement revient. Deux fois. Parfois des jours plus tard.
Tout ce qui suit existe pour rendre la deuxième livraison ennuyeuse.
Les trois façons dont ça tourne mal
La double exécution. L'événement arrive deux fois, vous créditez deux fois. Le client ne le remarque que quand ça joue dans l'autre sens.
L'exécution perdue. Vous vérifiez la signature, commencez le travail, et le délai expire. Le prestataire réessaie, votre handler plante sur un état à moitié écrit, et l'abonnement ne s'active jamais. Le client a payé ; votre base n'est pas d'accord.
La confiance dans la charge utile. Le corps du webhook dit que l'abonnement est actif. Entre la génération de l'événement et l'exécution de votre handler, le paiement a été contesté et l'abonnement annulé. Une signature valide prouve qui a envoyé le message, pas que le monde ressemble encore à ça.
Le motif
Quatre règles, dans l'ordre.
1. Lire le corps brut et vérifier la signature
La signature est calculée sur les octets exacts envoyés. Tout middleware qui parse et re-sérialise le JSON avant votre vérification la cassera ; la route du webhook doit donc lire le corps avant que quoi que ce soit n'y touche.
La fenêtre de tolérance de Stripe (300 secondes par défaut dans GoVueKit) compte aussi : c'est ce qui empêche un attaquant de rejouer le mois prochain une requête capturée et encore valide.
2. Répondre vite, travailler après
Les prestataires traitent un endpoint lent comme un endpoint en échec. Le handler accuse réception dès que l'événement est durablement enregistré, et le vrai travail se fait dans un worker en arrière-plan — qui, parce que la file est une table de la base, survit au redéploiement qui arrive trois secondes plus tard.
3. Dédupliquer avec une clé primaire, dans la même transaction que l'écriture
C'est la partie que les gens font presque bien. Le mode d'échec de « vérifier si traité, puis traiter, puis marquer traité », c'est un plantage entre les étapes deux et trois — ou deux livraisons en course.
La solution n'est pas un verrou. C'est une seule transaction :
BEGIN
INSERT INTO processed_events (id, ...) VALUES ($1, ...) -- clé primaire
-- violation d'unicité → cet événement est déjà fait, ROLLBACK et réponse 200
UPDATE subscriptions SET ... WHERE organization_id = $2
COMMIT
Soit les deux se produisent, soit aucun. Une livraison en double heurte la clé primaire et devient un no-op. Un plantage à mi-chemin annule tout et le réessai du prestataire rejoue proprement. Il n'y a aucune fenêtre où l'événement est marqué fait alors que l'écriture métier manque.
4. Relire l'objet par son identifiant ; ne jamais faire confiance à la charge utile
Le handler ne prend qu'une seule chose dans la charge utile : l'identifiant de l'objet. Puis il demande à l'API du prestataire à quoi cet objet ressemble maintenant, et écrit ça.
événement → id → provider.Resolve(ctx, ev) → état de référence → écriture
Ça coûte un appel d'API par événement et supprime toute une classe de conditions de course. Ça veut aussi dire qu'un événement vieux de six jours rejoué ne peut pas ressusciter un abonnement annulé : la relecture renvoie l'état courant.
Il y a un second bénéfice qui n'apparaît qu'en production : puisque rien
n'est lu dans la charge utile au-delà de l'identifiant, la version d'API
estampillée sur l'enveloppe cesse d'importer. La vérification contrôle la
signature et l'horodatage, délibérément pas la version, et chaque relecture
envoie la Stripe-Version du SDK — un tableau de bord qui se met à jour
sous vos pieds ne transforme donc pas chaque livraison en 400.
La même règle interdit de suivre des URL trouvées dans une charge utile — le
corps d'un webhook est une entrée influencée par l'attaquant, et suivre ses
liens est ainsi que commence le SSRF. Tout appel sortant de GoVueKit qui ne
vise pas un hôte codé en dur passe par le client durci safehttp, qui
valide l'adresse IP au moment de la connexion et refuse les plages privées.
Un fournisseur, derrière une couture
GoVueKit vend via Stripe. Une boutique a un seul marchand de référence, donc un second fournisseur n'est pas une fonctionnalité que le kit bascule — mais c'est un package de distance plutôt qu'une réécriture, parce que la page de facturation, les métriques d'admin et la ligne d'abonnement parlent à une interface, pas à Stripe :
type Provider interface {
Name() string
Checkout(ctx context.Context, org sqlcgen.Organization, actorEmail string, plan Plan) (string, error)
Portal(ctx context.Context, org sqlcgen.Organization, actorEmail string) (string, error)
VerifyWebhook(body []byte, header http.Header) (Event, error)
Resolve(ctx context.Context, ev Event) (Change, error)
}
Cinq méthodes. S'il vous faut Paddle, Lemon Squeezy ou un PSP local, c'est ce que vous implémentez, et rien au-dessus de la couture ne change. Soyez honnête avec vous-même sur la taille de ce travail : c'est une vraie intégration, pas un interrupteur de configuration, et le kit ne la livre pas.
Ce que la couture vous achète, c'est que la garantie voyage. La forme de la déduplication — une ligne, une clé primaire, une transaction — ne dépend pas de ce dont la clé est faite. Stripe vous donne un identifiant d'événement unique ; les fournisseurs qui ne le font pas vous obligent à construire la clé à partir du couple (nom d'événement, identifiant d'objet). Le motif survit à la substitution, ce qui est tout l'intérêt de l'écrire contre une interface.
L'argent qui sort compte aussi
Huit types d'événements couvrent l'argent qui arrive. Deux de plus couvrent celui qui repart, et ce sont ceux qu'on oublie :
charge.refundedestampille l'achat plutôt que de le supprimer. La ligne reste parce que c'est elle qui se réconcilie avec les registres du prestataire.charge.dispute.createdest journalisé au niveau Error, pour atteindre votre outil de remontée d'erreurs plutôt qu'un fichier de log que personne ne lit. Un litige a une échéance ; il doit se comporter comme un incident.
Les trois événements checkout.session.* comptent ensemble, pas
séparément. Une carte paie dans le checkout, donc completed rapporte déjà
payment_status=paid. Un moyen différé — le prélèvement SEPA, que Stripe
active volontiers sur un checkout en euros — se termine impayé et se
règle des jours plus tard en async_payment_succeeded, ou rebondit en
async_payment_failed. Abonnez-vous à completed seul et ces ventes ne sont
jamais exécutées, en silence.
Quoi tester
Trois tests, et ils sont rapides :
- Mauvaise signature → 400, rien d'écrit. L'erreur de configuration la plus courante en production est un secret de signature qui ne correspond pas ; vous la voulez bruyante.
- Le même événement livré deux fois → une seule écriture métier. Envoyez deux fois la charge utile identique et vérifiez que la ligne d'abonnement a changé une fois.
- Type d'événement inconnu → 200. Les prestataires ajoutent des types ; votre endpoint doit les ignorer sans erreur, sinon leur tableau de bord finira par désactiver votre webhook.
GoVueKit livre les trois comme tests d'intégration, plus un parcours Playwright qui poste un doublon signé à travers le vrai binaire. Le chemin non configuré est testé aussi : sans clé Stripe — ce qui est le défaut, et ce que la plupart des déploiements font tourner — les endpoints répondent un 501 délibéré et rien n'est écrit.
Notes d'exploitation
- Pointez Stripe vers
/api/billing/webhook, et abonnez-vous aux huit événements listés dans le README plutôt qu'à « tous les événements » : un endpoint qui reçoit tout est un endpoint dont les journaux ne disent rien. - Test local :
stripe listen --forward-to localhost:8080/api/billing/webhook. - Journalisez l'identifiant d'événement sur chaque branche. Quand un client dit « j'ai payé et rien ne s'est passé », l'identifiant transforme une conversation en requête SQL.
- Gardez
processed_eventspour toujours, ou au moins bien au-delà de la fenêtre de réessai du prestataire. C'est petit, et c'est votre preuve.
La règle sous tout ça est la même que celle qui gouverne la tenancy : faire du chemin sûr le seul chemin commode, puis écrire le test qui échoue quand quelqu'un trouve un raccourci.