Implémentation de la facturation Stripe : abonnements, factures et Webhooks
Stripe est la norme de facto pour l'infrastructure de facturation, mais sa mise en œuvre correcte (gestion des webhooks de manière idempotente, gestion des transitions d'état d'abonnement et récupération des paiements échoués) demande beaucoup plus de soin que ne le suggère la documentation de démarrage. Un système de paiement qui fonctionne 99 % du temps et échoue silencieusement les 1 % restants est pire qu'un système qui échoue bruyamment à 95 %.
Ce guide couvre une implémentation de production Stripe avec 6 types d'événements webhook, la gestion du cycle de vie des abonnements, l'approvisionnement automatique des licences et la gestion des remboursements. Les exemples de code utilisent NestJS 11, mais les modèles s'appliquent à n'importe quel framework backend.
Points clés à retenir
- Ne provisionnez jamais l'accès en fonction d'une redirection de paiement - attendez toujours le webhook
checkout.session.completed- Vérifiez les signatures des webhooks avec
stripe.webhooks.constructEvent()avant de traiter tout événement- Rendre tous les gestionnaires de webhooks idempotents : Stripe réessaye les webhooks ayant échoué pendant 72 heures maximum.
- Stockez les identifiants client, les identifiants d'abonnement et les identifiants de prix Stripe dans votre base de données - ne réinterrogez pas Stripe pour tout
- Gérer
invoice.payment_failedpour déclencher les emails de relance et la suspension de l'abonnementcharge.refundedetcheckout.session.completedcouvrent ensemble le cycle de vie complet de l'achat- Utilisez le champ
metadatade Stripe pour lier les objets Stripe à vos enregistrements internes- N'enregistrez jamais les charges utiles brutes des webhooks Stripe : elles contiennent des données de carte sensibles
Configuration du projet
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Stockez ces variables d'environnement :
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Création d'une session de paiement
Le flux de paiement : votre backend crée une session Stripe Checkout, le frontend redirige vers Stripe, Stripe collecte le paiement, puis appelle votre webhook. Ne gérez jamais le paiement vous-même – redirigez toujours vers Stripe Checkout.
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Gestionnaire de webhooks
Le gestionnaire de webhook est la partie la plus critique de l'intégration de Stripe. Chaque événement doit être idempotent :
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
Important : NestJS nécessite rawBody: true dans NestFactory.create() pour la vérification de la signature Stripe :
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
Gestionnaire de paiement terminé
Il s’agit du gestionnaire d’approvisionnement principal. Rendez-le idempotent :
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
Portail client
Permettez aux clients de gérer leurs propres abonnements sans contacter l’assistance :
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
Le portail client permet aux utilisateurs d'annuler des abonnements, de mettre à jour les méthodes de paiement, de télécharger des factures et d'afficher l'historique de facturation, sans aucun travail d'interface utilisateur personnalisé.
Intégration frontale
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
Test des webhooks localement
La CLI Stripe est essentielle pour le développement de webhooks locaux :
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
La CLI fournit votre secret de webhook local : utilisez-le dans votre .env.local :
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
Pièges courants et solutions
Piège 1 : Fourniture de l'accès lors de la redirection de paiement au lieu du webhook
La redirection de réussite du paiement peut être déclenchée par : un retour/suivant du navigateur, des onglets en double ou une nouvelle tentative du réseau. Provisionnez toujours en fonction du webhook checkout.session.completed, et non de l'URL de redirection.
Piège 2 : Ne pas gérer les tentatives de webhook (idempotence)
Stripe réessaye les webhooks ayant échoué pendant 72 heures. Sans contrôles d'idempotence, les nouvelles tentatives créent des commandes, des licences et des e-mails en double. Stockez l'ID de session Stripe dans votre table de commandes avec une contrainte unique : les insertions en double échouent correctement.
Piège 3 : Utilisation incorrecte du middleware Raw Body
La vérification de la signature Stripe nécessite le corps de la demande brut et non analysé. Si l'analyseur de corps JSON d'Express s'exécute en premier, le corps brut disparaît. Utilisez rawBody: true dans NestJS et accédez à req.rawBody, ou configurez le middleware JSON pour conserver le corps brut de la route du webhook spécifiquement.
Piège 4 : logique de nouvelle tentative manquante pour les appels d'API Stripe
Les appels d'API Stripe peuvent échouer de manière transitoire. Ajoutez une logique de nouvelle tentative pour les opérations idempotentes :
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
Questions fréquemment posées
Dois-je utiliser Stripe Checkout ou des formulaires de paiement personnalisés ?
Utilisez toujours Stripe Checkout, sauf si vous avez des exigences spécifiques qui rendent cela impossible (rare). Checkout gère automatiquement la conformité PCI, l'authentification 3D Secure, Apple Pay, Google Pay, SEPA et des dizaines d'autres méthodes de paiement. Les formulaires de paiement personnalisés (utilisant Stripe Elements) vous obligent à maintenir vous-même la conformité PCI et à mettre en œuvre chaque méthode de paiement manuellement. Pour 95 % des produits SaaS, Checkout est le bon choix.
Comment gérer le mode test de Stripe par rapport au mode live ?
Utilisez des configurations d'environnement distinctes : STRIPE_SECRET_KEY=sk_test_... en développement et STRIPE_SECRET_KEY=sk_live_... en production. Le mode test Stripe est complètement isolé du mode live : aucun frais de test n’affecte l’argent réel et aucune donnée live n’apparaît en mode test. Utilisez stripe.mode dans votre code pour vérifier que vous utilisez la bonne clé et ajoutez un indicateur bien visible dans votre interface utilisateur d'administration indiquant dans quel mode vous vous trouvez.
Que se passe-t-il si mon serveur webhook est en panne et que Stripe ne peut pas livrer ?
Stripe réessaye les webhooks ayant échoué selon un calendrier d'attente exponentiel pendant 72 heures maximum : 5 minutes, 30 minutes, 1 heure, 2 heures et des intervalles croissants. Au bout de 72 heures, l'événement est considéré comme ayant échoué. Utilisez le tableau de bord des événements de Stripe pour restituer manuellement les événements ayant échoué, ou interrogez stripe.events.list() et rejouez-les. Concevez vos gestionnaires pour qu'ils soient idempotents afin que la relivraison soit sûre.
Comment mettre en œuvre une facturation mesurée/basée sur l'utilisation ?
Stripe Billing prend en charge une utilisation mesurée via stripe.subscriptionItems.createUsageRecord(). Reportez l'utilisation à la fin de chaque période de facturation avec la quantité consommée. Tarifez les forfaits au compteur dans votre tableau de bord Stripe en tant que tarification « au compteur ». Le détail clé de la mise en œuvre : signaler l'utilisation avant la fin de la période de facturation, pas après - Stripe finalise la facture à la fin de la période.
Comment dois-je stocker les identifiants Stripe dans ma base de données ?
Stockez tous les ID Stripe sous forme de colonnes text (non numériques). Créez des index sur stripe_customer_id, stripe_subscription_id et stripe_payment_intent_id — vous rechercherez fréquemment des enregistrements à partir de ceux-ci. Ajoutez des contraintes uniques le cas échéant (un enregistrement client par utilisateur). Ne stockez jamais de numéros de carte de crédit ou de données brutes de paiement dans votre propre base de données.
Prochaines étapes
La mise en œuvre correcte de la facturation Stripe (avec l'idempotence des webhooks, la gestion du cycle de vie des abonnements, le provisionnement automatique et la gestion appropriée des remboursements) est une entreprise d'ingénierie non triviale. ECOSIRE gère 6 types d'événements de webhook Stripe en production avec un provisionnement automatique des licences et une intégration du portail client.
Si vous avez besoin d'une intégration Stripe, d'une infrastructure de facturation d'abonnement ou d'une assistance au développement SaaS full-stack, découvrez nos services de développement.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.