Intégrer des paiements dans un produit réel va bien au-delà du suivi de la documentation du fournisseur. Quand de l'argent réel circule dans le système, chaque défaillance silencieuse est un problème — des facturations dupliquées, un statut incohérent, ou des ventes qui n'ont jamais été confirmées. Dans cet article, je présente l'architecture que j'utilise pour recevoir et traiter les webhooks de fournisseurs comme Stripe, Mercado Pago et Asaas dans des applications Node.js.
Le vrai problème
Les webhooks sont la façon dont les fournisseurs de paiement notifient votre application des événements — un PIX confirmé, un abonnement annulé, un litige ouvert. Le problème, c'est que ce mécanisme est intrinsèquement peu fiable : les webhooks peuvent arriver dupliqués, dans le désordre, en retard, ou simplement ne jamais arriver.
Si votre application n'est pas préparée à gérer ces scénarios, vous découvrirez le problème quand un client se plaindra d'avoir payé sans recevoir son accès — ou pire, quand le service financier remarquera que les chiffres ne tombent pas juste.
Le diagramme ci-dessous montre le flux complet que nous allons construire dans cet article :
Diagramme du lifecycle d'un webhook de paiement — validation, idempotence, file, worker, retry et réconciliationValidation de signature
La première couche de sécurité consiste à valider que le webhook vient bien du fournisseur. Chaque fournisseur implémente cela différemment :
- Stripe : envoie un header
Stripe-Signatureavec timestamp + HMAC-SHA256 du body - Mercado Pago : envoie
x-signatureavec un hash et des query params, exige une validation via API - Asaas : envoie un token dans le header qui doit être comparé à celui configuré dans le tableau de bord
La règle est simple : ne traitez jamais un webhook sans valider la signature. Sans cela, n'importe qui peut envoyer un POST vers votre endpoint et simuler une confirmation de paiement.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// Traiter l'événement validé
await processEvent(event)
return new Response('OK', { status: 200 })
}Idempotence : éviter le traitement dupliqué
Les fournisseurs de paiement renvoient les webhooks quand ils ne reçoivent pas de réponse 2xx. Cela signifie que le même événement peut arriver plusieurs fois. Si votre handler n'est pas idempotent, vous pouvez créditer le solde d'un client deux fois ou envoyer deux emails de confirmation.
La solution consiste à stocker l'ID de l'événement et à vérifier avant de traiter :
async function processEvent(event: PaymentEvent) {
// Vérifier si cela a déjà été traité
const existing = await db.webhookEvents.findUnique({
where: { eventId: event.id }
})
if (existing) {
return // Déjà traité, on ignore
}
// Enregistrer l'événement avant de traiter
await db.webhookEvents.create({
data: {
eventId: event.id,
provider: 'stripe',
type: event.type,
processedAt: new Date()
}
})
// Traiter en toute sécurité
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data)
break
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data)
break
}
}Traitement asynchrone : ack immédiat
Une règle critique : répondez 200 le plus vite possible. Si votre handler met du temps à répondre (parce qu'il met à jour la base, envoie un email, appelle une autre API), le fournisseur va considérer que cela a échoué et renvoyer — ce qui provoque plus de charge et de possibles doublons.
Le pattern correct est ack immédiat + traitement en background :
export async function POST(req: Request) {
const event = await validateAndParse(req)
if (!event) {
return new Response('Invalid', { status: 400 })
}
// Enregistrer dans la file pour un traitement async
await db.webhookQueue.create({
data: {
eventId: event.id,
payload: JSON.stringify(event),
status: 'pending'
}
})
// Répondre immédiatement
return new Response('OK', { status: 200 })
}
// Un worker séparé traite la file
async function processQueue() {
const pending = await db.webhookQueue.findMany({
where: { status: 'pending' },
orderBy: { createdAt: 'asc' }
})
for (const item of pending) {
try {
await processEvent(JSON.parse(item.payload))
await db.webhookQueue.update({
where: { id: item.id },
data: { status: 'processed' }
})
} catch (err) {
await db.webhookQueue.update({
where: { id: item.id },
data: {
status: 'failed',
retryCount: { increment: 1 },
lastError: err.message
}
})
}
}
}Webhooks dans le désordre
Un scénario réel qui arrive fréquemment : le webhook payment.failed arrive avant payment.created. Ou le fournisseur envoie refund.completed avant payment.succeeded. Si votre système dépend d'un ordre spécifique, il va casser.
Deux approches pour gérer cela :
- State machine : définissez les transitions valides pour chaque statut de paiement. Si un événement tente une transition invalide (ex : refund avant success), mettez-le en file pour un retraitement ultérieur.
- Timestamp du fournisseur : utilisez le timestamp de l'événement (pas celui de la réception) pour déterminer quel état est le plus récent. Ignorez les événements plus anciens que l'état actuel.
async function handlePaymentUpdate(event: PaymentEvent) {
const payment = await db.payments.findUnique({
where: { providerPaymentId: event.paymentId }
})
if (!payment) {
// Le paiement n'existe pas encore, mettre en file pour un retry
await enqueueForRetry(event)
return
}
// Ignorer les événements plus anciens que l'état actuel
if (event.timestamp <= payment.lastEventTimestamp) {
return
}
// Valider la transition d'état
const validTransitions: Record<string, string[]> = {
pending: ['confirmed', 'failed', 'cancelled'],
confirmed: ['refunded', 'disputed'],
failed: ['pending'] // retry du fournisseur
}
if (!validTransitions[payment.status]?.includes(event.newStatus)) {
await enqueueForRetry(event)
return
}
await db.payments.update({
where: { id: payment.id },
data: {
status: event.newStatus,
lastEventTimestamp: event.timestamp
}
})
}PIX : différences entre les fournisseurs
Le PIX est le moyen de paiement le plus utilisé au Brésil, mais chaque fournisseur implémente la confirmation différemment :
- Stripe : n'offre pas le PIX nativement au Brésil (utilise des virements bancaires comme alternative)
- Mercado Pago : la confirmation du PIX arrive généralement en quelques secondes via webhook, mais peut être retardée jusqu'à plusieurs minutes aux heures de pointe
- Asaas : la confirmation peut prendre de quelques secondes à quelques minutes, et le webhook de confirmation arrive parfois avant le webhook de création
En pratique, cela signifie que vous ne pouvez pas vous fier à un ordre spécifique des événements pour le PIX. Le système doit être résilient aux confirmations qui arrivent avant la création, aux longs retards, et aux cas où le webhook n'arrive tout simplement pas.
Réconciliation : quand l'état diverge
Même avec toutes les protections ci-dessus, il y aura des moments où l'état local de votre application et l'état chez le fournisseur vont diverger. Un webhook qui n'est jamais arrivé, un bug dans le handler, un deploy qui a fait tomber le worker pendant quelques minutes.
La solution est un job de réconciliation qui tourne périodiquement :
async function reconcilePayments() {
// Chercher les paiements en attente depuis plus de 30 minutes
const stalePayments = await db.payments.findMany({
where: {
status: 'pending',
createdAt: {
lt: new Date(Date.now() - 30 * 60 * 1000)
}
}
})
for (const payment of stalePayments) {
// Consulter le statut directement chez le fournisseur
const providerStatus = await getProviderStatus(
payment.provider,
payment.providerPaymentId
)
if (providerStatus !== payment.status) {
await db.payments.update({
where: { id: payment.id },
data: { status: providerStatus }
})
// Log pour audit
await db.reconciliationLog.create({
data: {
paymentId: payment.id,
previousStatus: payment.status,
newStatus: providerStatus,
reason: 'reconciliation_job'
}
})
}
}
}Ce job est le filet de sécurité final. Il garantit que même quand tout échoue — webhooks perdus, bugs dans le handler, provider hors service — le système finit par converger vers l'état correct.
Dead letter queue : que faire quand ça échoue
Quand le traitement d'un webhook échoue de façon répétée (3-5 tentatives), il doit partir vers une dead letter queue — une table séparée pour les événements qui nécessitent une intervention manuelle ou une investigation.
async function processWithRetry(item: WebhookQueueItem) {
const MAX_RETRIES = 5
if (item.retryCount >= MAX_RETRIES) {
// Déplacer vers la dead letter queue
await db.deadLetterQueue.create({
data: {
eventId: item.eventId,
payload: item.payload,
lastError: item.lastError,
failedAt: new Date()
}
})
await db.webhookQueue.delete({
where: { id: item.id }
})
// Alerter l'équipe
await notify(`Webhook ${item.eventId} a échoué ${MAX_RETRIES}x et a été déplacé vers la DLQ`)
return
}
// Essayer de traiter avec un backoff exponentiel
try {
await processEvent(JSON.parse(item.payload))
} catch (err) {
const nextRetry = new Date(
Date.now() + Math.pow(2, item.retryCount) * 1000
)
await db.webhookQueue.update({
where: { id: item.id },
data: {
retryCount: { increment: 1 },
lastError: err.message,
nextRetryAt: nextRetry
}
})
}
}Dégradation élégante
Quand un fournisseur de paiement est hors service, votre application ne peut pas simplement casser. L'utilisateur doit savoir que le paiement est en cours de traitement, même si la confirmation prend du temps. Quelques pratiques :
- Statut intermédiaire : utilisez un état comme
awaiting_confirmationque l'utilisateur voit pendant qu'il attend la confirmation du fournisseur - Timeouts configurables : si la confirmation n'arrive pas en X minutes, marquez-le comme
requires_reviewau lieu d'échouer silencieusement - Fallback entre fournisseurs : si un fournisseur est hors service, proposez un autre moyen de paiement comme alternative
- Communication claire : notifiez l'utilisateur du statut réel au lieu d'afficher un loading éternel
Résumé de l'architecture
L'architecture complète pour les webhooks de paiement en production se résume à ces couches :
- Validation de signature — rejeter tout webhook non authentifié
- Idempotence — stocker les IDs d'événements et ignorer les doublons
- Ack immédiat — répondre 200 et traiter en background
- State machine — valider les transitions d'état et gérer les événements dans le désordre
- Réconciliation — job périodique qui se synchronise avec le fournisseur
- Dead letter queue — les événements qui ont échoué de façon répétée partent en investigation
- Dégradation élégante — le système continue de fonctionner quand le fournisseur échoue
Chaque couche est un filet de sécurité pour la précédente. Aucune d'entre elles ne résout le problème à elle seule — c'est la combinaison qui rend le système suffisamment fiable pour manipuler de l'argent réel en production.
