Intégrer un système de paiement fiable est souvent une étape redoutée dans un projet web. Pourtant, avec Stripe et NestJS, l'opération devient à la fois élégante et robuste, à condition de respecter quelques règles essentielles : vérification des signatures, idempotence des webhooks, et séparation claire des responsabilités. Dans ce tutoriel, nous allons construire pas à pas un PaymentModule complet permettant de créer une session Stripe Checkout et de traiter les webhooks de manière sécurisée.
Prérequis
- Un projet NestJS fonctionnel (v10+)
- Node.js 18 ou supérieur
- Un compte Stripe (mode test suffit) avec une clé secrète
sk_test_... - TypeORM configuré (pour stocker l'historique des events Stripe)
- La CLI Stripe pour tester les webhooks en local
Installation et configuration
On commence par installer le SDK officiel de Stripe :
npm install stripe
Ajoutez ensuite vos variables d'environnement dans le fichier .env :
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
FRONTEND_URL=http://localhost:3000
Et exposez-les via un fichier de configuration consommable par ConfigService :
// src/config/configuration.ts
export default () => ({
frontendUrl: process.env.FRONTEND_URL,
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
});
Activer le rawBody dans main.ts
Stripe signe ses webhooks à partir du corps brut de la requête HTTP. Si NestJS parse le JSON avant la vérification, la signature ne correspondra plus. Il faut donc activer rawBody dans le bootstrap de l'application :
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // indispensable pour Stripe
});
app.enableCors({ origin: process.env.FRONTEND_URL, credentials: true });
await app.listen(3001);
}
bootstrap();
Le PaymentModule
Le module regroupe le contrôleur, le service, et déclare les dépendances nécessaires : ConfigModule pour les clés, TypeOrmModule pour l'entité StripeEvent, ainsi que les modules métier ServicesModule et BookingModule.
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaymentService } from './payment.service';
import { PaymentController } from './payment.controller';
import { ServicesModule } from '../services/services.module';
import { BookingModule } from '../booking/booking.module';
import { StripeEvent } from '../../database/entities/stripe-event.entity';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([StripeEvent]),
ServicesModule,
forwardRef(() => BookingModule),
],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}
Le forwardRef sur BookingModule permet d'éviter les dépendances circulaires : le PaymentService met à jour les bookings après paiement, tandis que le BookingService peut déclencher la création de sessions Stripe.
L'entité StripeEvent pour l'idempotence
Stripe peut renvoyer plusieurs fois le même webhook en cas de doute sur la réception (timeout, erreur 500…). Sans précaution, vous risquez de confirmer deux fois la même réservation. La solution : stocker chaque event.id traité.
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity('stripe_events')
export class StripeEvent extends BaseEntity {
@Column({ name: 'event_id', unique: true })
@Index()
eventId: string;
@Column({ name: 'event_type' })
eventType: string;
@Column({ default: false })
processed: boolean;
}
Le DTO de création de session
On valide strictement les données reçues du frontend grâce à class-validator. Notez l'usage de @IsUUID() pour les identifiants, des longueurs maximales pour éviter les abus, et de @IsOptional() pour les champs facultatifs.
export class CreateCheckoutDto {
@IsUUID()
@IsNotEmpty()
serviceId: string;
@IsUUID()
@IsOptional()
bookingId?: string;
@IsDateString()
@IsOptional()
date?: string;
@IsString()
@IsOptional()
@MaxLength(10)
time?: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsEmail()
@IsNotEmpty()
@MaxLength(254)
email: string;
@IsString()
@IsOptional()
@MaxLength(5000)
question?: string;
}
Le PaymentService : créer une session Checkout
Le service instancie le client Stripe à la construction, puis expose une méthode createCheckoutSession. On utilise price_data en ligne (sans créer de produit côté Stripe) et on transmet toutes les informations utiles via metadata. Ces métadonnées seront récupérées dans le webhook pour mettre à jour la réservation.
@Injectable()
export class PaymentService {
private stripe: Stripe | null = null;
private readonly logger = new Logger(PaymentService.name);
constructor(
private readonly configService: ConfigService,
private readonly servicesService: ServicesService,
private readonly bookingService: BookingService,
@InjectRepository(StripeEvent)
private readonly stripeEventRepository: Repository<StripeEvent>,
) {
const secretKey = this.configService.get<string>('stripe.secretKey');
if (secretKey) {
this.stripe = new Stripe(secretKey);
}
}
async createCheckoutSession(
dto: CreateCheckoutDto,
origin: string,
): Promise<{ sessionId: string; url: string | null }> {
if (!this.stripe) {
throw new BadRequestException('Le système de paiement est temporairement indisponible');
}
const service = await this.servicesService.findOne(dto.serviceId);
const frontendUrl = this.configService.get<string>('frontendUrl');
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ['card'],
customer_email: dto.email,
line_items: [{
price_data: {
currency: 'eur',
product_data: {
name: `${service.title} - ${service.subtitle}`,
description: dto.date
? `Date: ${dto.date}, Heure: ${dto.time}`
: service.duration,
},
unit_amount: service.price, // en centimes
},
quantity: 1,
}],
mode: 'payment',
success_url: `${frontendUrl}/reservation?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${frontendUrl}/reservation?canceled=true`,
metadata: {
serviceId: dto.serviceId,
bookingId: dto.bookingId || '',
date: dto.date || '',
time: dto.time || '',
name: dto.name,
},
});
return { sessionId: session.id, url: session.url };
}
}
Point d'attention : unit_amount est exprimé en centimes. Un service à 50 € correspond à 5000. Le placeholder {CHECKOUT_SESSION_ID} dans success_url est remplacé automatiquement par Stripe lors de la redirection.
Le contrôleur : exposer les endpoints
On expose trois routes publiques (sans JWT, car le checkout est ouvert aux visiteurs anonymes), avec rate limiting pour le checkout et @SkipThrottle() pour le webhook.
@ApiTags('payment')
@Controller('payment')
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
@Post('checkout')
@Public()
@Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout(
@Body() dto: CreateCheckoutDto,
@Headers('origin') origin: string,
): Promise<ApiResponseDto<{ sessionId: string; url: string | null }>> {
const result = await this.paymentService.createCheckoutSession(dto, origin);
return ApiResponseDto.success(result, 'Checkout session created successfully');
}
@Post('webhook')
@Public()
@SkipThrottle()
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
): Promise<ApiResponseDto<{ received: boolean }>> {
if (!req.rawBody) {
throw new BadRequestException('Raw body is required for webhook verification');
}
const result = await this.paymentService.handleWebhook(req.rawBody, signature);
return ApiResponseDto.success(result, 'Webhook processed');
}
}
Traiter les webhooks en toute sécurité
C'est le cœur du système. Trois étapes critiques :
- Vérifier la signature avec
stripe.webhooks.constructEvent() - Garantir l'idempotence via la table
stripe_events - Dispatcher l'event selon son type (
checkout.session.completed,payment_intent.payment_failed…)
async handleWebhook(payload: Buffer, signature: string) {
const webhookSecret = this.configService.get<string>('stripe.webhookSecret');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (err: any) {
this.logger.warn(`Signature webhook invalide: ${err.message}`);
throw new BadRequestException('Signature webhook invalide');
}
// Idempotence : a-t-on déjà traité cet event ?
const existing = await this.stripeEventRepository.findOne({ where: { eventId: event.id } });
if (existing?.processed) {
this.logger.log(`Event ${event.id} déjà traité, ignoré`);
return { received: true, event: event.type };
}
if (!existing) {
await this.stripeEventRepository.save({
eventId: event.id,
eventType: event.type,
processed: false,
});
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const { bookingId } = session.metadata || {};
if (bookingId) {
await this.bookingService.updateStatus(
bookingId,
BookingStatus.CONFIRMED,
{
sessionId: session.id,
paymentIntentId: session.payment_intent as string,
amount: session.amount_total || undefined,
},
);
}
break;
}
case 'checkout.session.expired': {
const session = event.data.object as Stripe.Checkout.Session;
this.logger.log(`Session expirée: ${session.id}`);
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
this.logger.warn(`Paiement échoué: ${pi.id}`);
break;
}
default:
this.logger.log(`Event non géré: ${event.type}`);
}
await this.stripeEventRepository.update({ eventId: event.id }, { processed: true });
} catch (error) {
this.logger.error(`Erreur traitement webhook ${event.id}: ${error.message}`);
// On retourne quand même 200 : Stripe ne retentera pas indéfiniment
}
return { received: true, event: event.type };
}
Toujours retourner un statut 2xx à Stripe, même en cas d'erreur métier. Sinon, Stripe va retenter l'envoi pendant plusieurs jours et saturer vos logs. Loguez l'erreur, créez une alerte, mais accusez réception.
Tester en local avec la CLI Stripe
Pour tester les webhooks, installez la CLI Stripe et lancez :
stripe login
stripe listen --forward-to localhost:3001/payment/webhook
La CLI affiche un secret whsec_... à utiliser dans STRIPE_WEBHOOK_SECRET. Vous pouvez ensuite déclencher un event à la demande :
stripe trigger checkout.session.completed
Bonnes pratiques et pièges à éviter
- Ne jamais faire confiance au frontend pour le montant : recalculez-le côté serveur depuis la base de données.
- Ne jamais confirmer une réservation depuis la
success_url: un utilisateur malveillant peut y accéder sans avoir payé. Le webhook est la seule source de vérité. - Activez le rawBody uniquement sur l'endpoint webhook si vous utilisez
body-parserglobalement, pour ne pas casser les autres routes. - Surveillez les events non traités : un job périodique peut alerter sur les
StripeEventavecprocessed = falsedepuis plus de 5 minutes. - Utilisez les modes test et live avec des clés distinctes par environnement.
Conclusion
Vous disposez maintenant d'un module de paiement complet, sécurisé et résilient : création de sessions Checkout, vérification cryptographique des webhooks, idempotence garantie par base de données, et mise à jour automatique des réservations après paiement. Cette architecture sépare proprement les responsabilités entre le contrôleur (HTTP), le service (logique métier) et l'entité StripeEvent (persistance technique).
Pour aller plus loin, vous pourriez ajouter la gestion des remboursements via refunds.create(), l'envoi d'emails de confirmation dans un handler du webhook, ou encore la mise en place d'abonnements récurrents avec mode: 'subscription'. La documentation officielle Stripe et la documentation NestJS sont vos meilleures alliées pour explorer ces sujets.




Commentaires