VoltFlow — webhooks Stripe, sync abonnement et idempotence

July 12, 2026

Contexte

VoltFlow est la 4ᵉ brique de Meridian : le backend FastAPI (Railway) qui gère Stripe pour GreenOps. Pas de frontend dédié — GreenOps appelle VoltFlow en service-to-service (X-VoltFlow-Service-Key) et affiche /billing.

L’article GreenOps — Stripe billing en prod couvre l’UX et l’architecture bout en bout. Ici : ce qui se passe dans VoltFlow quand Stripe parle à l’API — webhooks, sync DB, idempotence.

Rôle de VoltFlow dans la chaîne

Stripe Checkout / Portal
         webhook signé
POST /webhooks/stripe  (VoltFlow · Railway)
         upsert / update
Supabase · table subscriptions
         lecture
GreenOps · isOrgPro() · gate export PDF

Règle V1 : Stripe n’écrit jamais directement dans GreenOps. VoltFlow est le seul writer sur subscriptions et billing_lines (service role Supabase). GreenOps reste lecture + proxy Checkout.

Webhook : les 3 événements gérés

| Événement Stripe | Action VoltFlow | |------------------|-----------------| | checkout.session.completed | Lie org_idstripe_customer_id, plan pro, status active | | customer.subscription.updated | Met à jour plan/status/période selon status Stripe | | customer.subscription.deleted | Downgrade free (annulation effective) |

Handler dans app/api/routes.py :

event = stripe_service.verify_webhook_signature(payload, stripe_signature)
data_object = event["data"]["object"].to_dict()

if event_type == "checkout.session.completed":
    billing.upsert_subscription_from_checkout(data_object)
elif event_type in ("customer.subscription.updated", "customer.subscription.deleted"):
    billing.upsert_subscription_from_stripe_subscription(data_object)

return {"received": True}

Pourquoi to_dict() ? À partir de stripe-python 15, les StripeObject ne se comportent plus comme des dicts — .get() lève AttributeError. En prod, tous les webhooks renvoyaient 500 malgré une signature valide. Conversion explicite à la frontière du handler ; test E2E tests/test_webhook_route.py pour éviter la régression au prochain bump SDK.

Lier une org GreenOps à un client Stripe

Au Checkout, VoltFlow passe l’identifiant org dans client_reference_id (et metadata.org_id en secours) :

session = client.checkout.Session.create(
    mode="subscription",
    client_reference_id=org_id,
    metadata={"org_id": org_id},
    ...
)

Le webhook checkout.session.completed lit ce champ et fait un upsert sur org_id :

client.table("subscriptions").upsert(row, on_conflict="org_id").execute()

→ Idempotent côté abonnement : un second événement pour la même org écrase la ligne, pas de doublon.

Sync abonnement : active → Pro, canceled → Free

plan_from_status() mappe les statuts Stripe :

| Status Stripe | Plan VoltFlow | |---------------|---------------| | active, trialing | pro | | canceled, past_due, … | free |

Les mises à jour passent par stripe_customer_id (pas org_id) — c’est l’ID que Stripe envoie dans subscription.updated / deleted.

GreenOps interroge ensuite GET /api/v1/subscriptions/{org_id} pour le badge plan et la gate PDF.

Idempotence usage : une ligne par flex_slot

Au-delà de l’abonnement, VoltFlow trace l’usage metered : chaque créneau flex Pro génère une ligne billing_lines.

record_usage(org_id, flex_slot_id) :

  1. Cherche une ligne existante pour ce flex_slot_id
  2. Si trouvée → retourne la ligne (pas de double facturation)
  3. Sinon → calcule le prix dynamique (compute_line_pricing) et insert
existing = _get_existing_line(flex_slot_id)
if existing:
    return existing

Test : test_record_usage_is_idempotent_for_same_slot. GreenOps appelle /usage/record en best-effort après création d’un slot — si VoltFlow est down, le flux ops ne bloque pas.

Sécurité : signature avant tout traitement

verify_webhook_signature() utilise stripe.Webhook.construct_event() avec STRIPE_WEBHOOK_SECRET. Payload brut + header Stripe-Signature — pas de JSON parsé avant vérification.

Les routes métier (/checkout/session, /usage/record, …) sont protégées par X-VoltFlow-Service-Key — seul GreenOps (ou un cron interne) peut les appeler.

| Surface | Protection | |---------|------------| | /webhooks/stripe | Signature Stripe | | /checkout/session, /usage/record, … | Clé service VoltFlow | | Lecture subscriptions côté GreenOps | RLS Supabase (membres de l’org) |

Tests : unitaires + route E2E

19 tests VoltFlow, dont :

  • Crypto : test_webhook_signature.py — payload valide, secret faux, payload altéré
  • Sync : test_billing.py — upsert checkout, downgrade cancel, idempotence usage
  • Route : test_webhook_route.py — POST /webhooks/stripe avec signature HMAC réelle, mock Supabase

La couche route est importante : les fonctions billing.py attendent des dicts, le SDK Stripe livre des objets — seul un test HTTP bout en bout attrape le mismatch.

Déploiement

| Où | Variable | |----|----------| | Railway (VoltFlow) | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID_PRO, SUPABASE_SERVICE_ROLE_KEY, VOLTFLOW_INTEGRATION_SECRET | | Stripe Dashboard | Endpoint webhook → https://…railway…/webhooks/stripe | | GreenOps (Vercel) | VOLTFLOW_API_URL, VOLTFLOW_SERVICE_KEY (= même secret) |

Local : stripe listen --forward-to localhost:8000/webhooks/stripe pour rejouer les événements sans déployer.

Ce que ce chantier démontre

  • Tenir un webhook Stripe en prod (signature, sync, downgrade) — pas un slide « on pourrait brancher Stripe ».
  • Idempotence à deux niveaux : upsert abonnement par org_id, usage par flex_slot_id.
  • Isoler le billing dans un micro-service sans dupliquer l’auth GreenOps.
  • Débugger une régression SDK visible uniquement en prod — compétence SaaS B2B rare en portfolio junior.

Dépôt : github.com/ChristopheChollet/VoltFlow · Intégration GreenOps : github.com/ChristopheChollet/GreenOps · Démo billing : green-ops-five.vercel.app/billing