Stack technique

Stripe + Convex : abonnements SaaS en 200 lignes

Implémentation complète de subscriptions Stripe avec Convex : checkout session, webhook handler, sync DB, customer portal. Code copiable, prêt pour la production.

KIKillian
··6 min read
Carte bancaire et terminal de paiement représentant les abonnements Stripe

Brancher Stripe sur un SaaS, c'est 80 % de doc et 20 % de code. Avec Convex, on tombe à 200 lignes vraiment écrites — checkout, webhook handler, customer portal, sync DB compris. Voici l'implémentation exacte d'ApexKit, avec les pièges qui m'ont fait perdre du temps.

L'architecture

Trois flux à coder :

  1. Checkout. L'utilisateur clique « S'abonner » → on crée une session Stripe Checkout → on le redirige.

  2. Webhook. Stripe nous notifie des events (checkout.session.completed, customer.subscription.updated, etc.) → on met à jour la DB Convex.

  3. Portal. L'utilisateur clique « Gérer mon abonnement » → on crée une session Stripe Customer Portal → il gère lui-même upgrade/downgrade/cancel.

On ne stocke rien d'autre que le stripeCustomerId, le subscriptionId, le status et la currentPeriodEnd. Tout le reste vit chez Stripe — source of truth.

Étape 1 — Schéma Convex

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  subscriptions: defineTable({
    userId: v.id("users"),
    stripeCustomerId: v.string(),
    stripeSubscriptionId: v.string(),
    stripePriceId: v.string(),
    status: v.union(
      v.literal("trialing"),
      v.literal("active"),
      v.literal("past_due"),
      v.literal("canceled"),
      v.literal("incomplete"),
    ),
    currentPeriodEnd: v.number(),
    cancelAtPeriodEnd: v.boolean(),
  })
    .index("by_user", ["userId"])
    .index("by_stripe_customer", ["stripeCustomerId"]),
});

Étape 2 — Créer la session Checkout

Une action Convex (runtime Node) qui appelle l'API Stripe et renvoie l'URL de redirect :

// convex/billing.ts
"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export const createCheckoutSession = action({
  args: { priceId: v.string() },
  handler: async (ctx, args): Promise<{ url: string }> => {
    const user = await ctx.runQuery(api.users.me);
    if (!user) throw new Error("Non authentifié");

    // Récupère ou crée le customer Stripe
    let customerId = user.stripeCustomerId;
    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        metadata: { userId: user._id },
      });
      customerId = customer.id;
      await ctx.runMutation(api.users.setStripeCustomer, {
        userId: user._id,
        stripeCustomerId: customerId,
      });
    }

    const session = await stripe.checkout.sessions.create({
      mode: "subscription",
      customer: customerId,
      line_items: [{ price: args.priceId, quantity: 1 }],
      success_url: `${process.env.SITE_URL}/dashboard?upgraded=true`,
      cancel_url: `${process.env.SITE_URL}/pricing`,
      allow_promotion_codes: true,
    });

    return { url: session.url! };
  },
});

Côté React, l'utilisateur clique → window.location.href = url. Stripe gère tout : carte, 3DS, taxes, factures.

Tableau de bord d'analyse de revenus pour un SaaS avec graphique de croissance

Étape 3 — Webhook handler

C'est la pièce centrale. Convex expose les httpAction pour ça : un endpoint HTTP qui reçoit les events Stripe, valide la signature, et update la DB.

// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

const http = httpRouter();

http.route({
  path: "/stripe-webhook",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const sig = request.headers.get("stripe-signature")!;
    const body = await request.text();

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
    } catch {
      return new Response("Invalid signature", { status: 400 });
    }

    switch (event.type) {
      case "checkout.session.completed":
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const sub = event.data.object as Stripe.Subscription;
        await ctx.runMutation(internal.billing.upsertSubscription, {
          stripeCustomerId: sub.customer as string,
          stripeSubscriptionId: sub.id,
          stripePriceId: sub.items.data[0].price.id,
          status: sub.status,
          currentPeriodEnd: sub.current_period_end * 1000,
          cancelAtPeriodEnd: sub.cancel_at_period_end,
        });
        break;
      }
    }

    return new Response(null, { status: 200 });
  }),
});

export default http;

Gotcha critique : il faut utiliser request.text() pour récupérer le body brut — pas request.json(). Sinon la signature ne valide pas (le parsing modifie les espaces).

Étape 4 — Mutation interne upsertSubscription

// convex/billing.ts (suite)
import { internalMutation } from "./_generated/server";

export const upsertSubscription = internalMutation({
  args: {
    stripeCustomerId: v.string(),
    stripeSubscriptionId: v.string(),
    stripePriceId: v.string(),
    status: v.string(),
    currentPeriodEnd: v.number(),
    cancelAtPeriodEnd: v.boolean(),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_stripe_customer", q =>
        q.eq("stripeCustomerId", args.stripeCustomerId))
      .unique();
    if (!user) throw new Error("User introuvable");

    const existing = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", q => q.eq("userId", user._id))
      .unique();

    const patch = {
      userId: user._id,
      ...args,
      status: args.status as any,
    };

    if (existing) {
      await ctx.db.patch(existing._id, patch);
    } else {
      await ctx.db.insert("subscriptions", patch);
    }
  },
});

Étape 5 — Customer Portal

Un seul appel API et tu offres à tes users : changer de plan, mettre à jour la CB, télécharger les factures, annuler. Tout l'UX géré par Stripe.

export const createPortalSession = action({
  args: {},
  handler: async (ctx) => {
    const user = await ctx.runQuery(api.users.me);
    if (!user?.stripeCustomerId) throw new Error("Pas de customer");

    const session = await stripe.billingPortal.sessions.create({
      customer: user.stripeCustomerId,
      return_url: `${process.env.SITE_URL}/dashboard`,
    });

    return { url: session.url };
  },
});

Étape 6 — Gating dans les queries

Pour bloquer une feature aux abonnés payants, tu lis la subscription dans la query elle-même :

export const premiumFeature = query({
  args: {},
  handler: async (ctx) => {
    const user = await getAuthUser(ctx);
    const sub = await ctx.db
      .query("subscriptions")
      .withIndex("by_user", q => q.eq("userId", user._id))
      .unique();

    if (!sub || sub.status !== "active") {
      throw new Error("Abonnement requis");
    }
    return { /* data premium */ };
  },
});

Comme les queries Convex sont réactives, dès que le webhook update le status à "active", l'UI se déverrouille automatiquement.

Tester en local

Stripe CLI fait le job parfaitement :

# Forward les webhooks vers ton endpoint Convex dev
stripe listen --forward-to https://your-deployment.convex.site/stripe-webhook

# Trigger un event de test
stripe trigger customer.subscription.created

Le CLI affiche le secret de signing à utiliser dans STRIPE_WEBHOOK_SECRET local.

Pièges qui m'ont coûté du temps

  • Toujours utiliser le customer ID. Si tu crées une session Checkout sans customer, Stripe en crée un nouveau à chaque fois → factures dupliquées, impossible à raccorder.

  • Les events arrivent dans le désordre. subscription.updated peut arriver avant checkout.session.completed. Toujours upsert idempotemment.

  • Currency lock. Un customer Stripe est lié à une devise. Si tu changes EUR ↔ USD, il faut recréer le customer.

  • Tax. Active Stripe Tax dès le départ — sinon refacturer rétroactivement est l'enfer.

Bilan : 200 lignes, tout y est

Le code complet : schema.ts (15 lignes), billing.ts (80 lignes), http.ts (40 lignes), composants React (60 lignes). 200 lignes pour checkout + sync + portal + gating. Stripe et Convex font le reste.

Pour ne pas réinventer la roue

Si tu veux récupérer cette implémentation déjà câblée — avec UI pricing, gestion des trial, gating multi-tier, emails de confirmation et tests d'intégration — c'est exactement ce que livre ApexKit. La version Stripe + Convex que tu vois dans cet article tourne en prod chez moi depuis 6 mois.


Construire ton SaaS encore plus vite

ApexKit est le template full-stack TypeScript que j'ai construit pour livrer un SaaS en une semaine : auth passwordless, multi-tenant, billing Stripe, blog, docs et dashboard admin — câblés ensemble avec TanStack Start et Convex.

Découvrir ApexKit →

Web seul ou Web + mobile (Expo), source-of-truth Convex partagée, prêt à déployer sur VPS ou Vercel en quelques minutes.

Testimonials

Command palette

Search and run actions.