Vinicius Aguiar
Architecture

Architecture de webhooks pour les fournisseurs de paiement en Node.js

15 avril 2026 · 10 min de lecture

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éconciliationDiagramme du lifecycle d'un webhook de paiement — validation, idempotence, file, worker, retry et réconciliation

Validation 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-Signature avec timestamp + HMAC-SHA256 du body
  • Mercado Pago : envoie x-signature avec 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_confirmation que 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_review au 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 :

  1. Validation de signature — rejeter tout webhook non authentifié
  2. Idempotence — stocker les IDs d'événements et ignorer les doublons
  3. Ack immédiat — répondre 200 et traiter en background
  4. State machine — valider les transitions d'état et gérer les événements dans le désordre
  5. Réconciliation — job périodique qui se synchronise avec le fournisseur
  6. Dead letter queue — les événements qui ont échoué de façon répétée partent en investigation
  7. 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.