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.
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 :
Checkout. L'utilisateur clique « S'abonner » → on crée une session Stripe Checkout → on le redirige.
Webhook. Stripe nous notifie des events (
checkout.session.completed,customer.subscription.updated, etc.) → on met à jour la DB Convex.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.
É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.createdLe 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.updatedpeut arriver avantcheckout.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.
Web seul ou Web + mobile (Expo), source-of-truth Convex partagée, prêt à déployer sur VPS ou Vercel en quelques minutes.