Webhooks Stripe : signature, idempotence et fiabilité des paiements
Architecture de traitement résiliente des événements Stripe : vérification cryptographique, idempotence par event.id, gestion des retries et découplage.
Sur cette page (11 sections)▼
Les webhooks constituent le canal de communication asynchrone par lequel Stripe informe votre application d'événements financiers survenus sur sa plateforme : confirmation d'un paiement (payment_intent.succeeded), échec de renouvellement d'abonnement (invoice.payment_failed), contestation ou remboursement.
Dans une architecture web moderne, le webhook est la seule source de vérité pour déclencher la livraison d'un service ou l'attribution de droits payants. Se fier à la simple redirection de succès dans le navigateur du client après paiement (page return_url) est une faille critique : l'utilisateur peut fermer son navigateur avant la redirection, perdre sa connexion ou manipuler l'URL locale.
Cependant, exposer un point de terminaison (endpoint) HTTP public capable de modifier l'état financier de votre base de données introduit des exigences d'ingénierie strictes.
Pourquoi HTTP 200 n'est pas synonyme de succès métier
Le protocole HTTP et le cycle de vie d'un webhook opèrent à deux niveaux distincts :
[Stripe Servers] ────── Requête POST (Event JSON) ──────► [Votre Endpoint /api/webhooks/stripe]
▲ │
│ ▼
Code HTTP (200 ou 5xx) ◄─── Réponse immédiate (ACK) ──── Traitement métier / Base
- Le statut HTTP 200 (Acquittement de réception) : Indique uniquement à Stripe que votre serveur a bien reçu la charge utile (payload), que la signature cryptographique est valide et que Stripe ne doit pas réémettre immédiatement cet événement.
- Le succès métier : Concerne l'exécution des opérations en base de données (mise à jour du statut client, provisionnement des crédits, génération d'une facture).
Si votre handler retourne HTTP 200 après avoir attrapé silencieusement une erreur (try / catch vide sans journalisation ni alerte), Stripe considère l'événement comme traité avec succès alors que le client n'a jamais reçu son achat en base. Inversement, si votre handler renvoie une erreur HTTP 500 après avoir mis à jour la base (par exemple parce qu'un email transactionnel a échoué en fin de fonction), Stripe réessayera d'envoyer l'événement, risquant d'exécuter l'action une seconde fois.
La vérification cryptographique de signature
Tout individu connaissant l'URL de votre endpoint peut lui envoyer une requête POST forgée prétendant qu'un paiement de 10 000 € a été validé. Pour neutraliser cette menace, Stripe signe cryptographiquement chaque requête à l'aide d'un secret partagé (STRIPE_WEBHOOK_SECRET, préfixé par whsec_).
Le piège du corps de requête (Raw Body)
La vérification de signature s'effectue à l'aide de l'en-tête stripe-signature et du corps brut de la requête (raw body). L'empreinte HMAC SHA-256 est calculée sur la chaîne d'octets exacte émise par Stripe.
Si votre framework (comme Next.js avec req.json()) parse le corps JSON avant le passage au SDK Stripe, des variations infimes (espaces blancs, encodage des caractères, ordre des clés) modifient le hachage calculé, provoquant une erreur systématique Webhook signature verification failed.
Implémentation de référence sous Next.js (App Router)
Dans Next.js App Router, le corps brut doit être extrait avec req.text() :
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'En-tête stripe-signature manquant' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Signature invalide';
console.error(`[Webhook Stripe] Échec de vérification : ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
// Traitement sécurisé de l'événement validé...
return NextResponse.json({ received: true }, { status: 200 });
}
L'idempotence des événements réémis
Par nature, les réseaux distribués ne peuvent pas garantir une livraison strictement unique (exactly-once delivery). Stripe applique une garantie de livraison au moins une fois (at-least-once delivery). Cela signifie qu'un même événement peut être envoyé plusieurs fois à votre serveur.
event.id de webhook vs Idempotency Key de l'API Stripe
Il est essentiel de ne pas confondre deux mécanismes d'idempotence chez Stripe :
- L'en-tête
Idempotency-Keylors des appels sortants vers Stripe : Utilisé lorsque votre serveur appelle l'API Stripe (création d'une charge, émission d'un remboursement) pour éviter de prélever deux fois un client en cas de timeout réseau. - L'identifiant
event.idreçu dans le webhook entrant : Identifiant immuable et universellement unique de l'événement Stripe (exemple :evt_3MtwLwLkdIwHu7ix28a3tqPa). C'est cette valeur qui doit être persistée dans votre base pour dédupliquer les réceptions.
Pattern de persistance et déduplication atomique
Avant d'exécuter un effet de bord métier (créditer un portefeuille ou envoyer une notification), votre application doit vérifier si l'identifiant event.id a déjà été traité :
-- Table dédiée au suivi des événements Stripe
CREATE TABLE public.stripe_processed_events (
id text PRIMARY KEY,
event_type text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Dans votre handler :
// Insertion atomique ou déduplication par clé primaire
const { error } = await supabase
.from('stripe_processed_events')
.insert({ id: event.id, event_type: event.type });
if (error) {
// Le code 23505 correspond à une violation de contrainte unique dans PostgreSQL
if (error.code === '23505') {
console.warn(`[Webhook Stripe] Événement déjà traité : ${event.id}`);
return NextResponse.json({ status: 'already_processed' }, { status: 200 });
}
throw error;
}
// Exécution du traitement métier une seule et unique fois
await fulfillOrder(event.data.object);
Politique de retry de Stripe et désordre des événements
Stripe applique une politique de relance automatique agressive en cas d'indisponibilité ou d'erreur de votre serveur :
- Si votre serveur répond avec un code d'erreur (
5xxou4xxhors cas spécifiques) ou ne répond pas dans un délai de 20 secondes, Stripe réessaie selon un intervalle exponentiel (exponential backoff) sur plusieurs jours. - Le désordre des événements (out-of-order delivery) : Les événements ne sont pas garantis d'arriver dans l'ordre chronologique exact de leur génération. Par exemple, un événement
customer.subscription.updatedpeut exceptionnellement atteindre votre serveur quelques millisecondes avantcustomer.subscription.created. - Pour vous prémunir du désordre, fiez-vous systématiquement à l'horodatage interne de l'objet Stripe (
createdoucurrent_period_end) plutôt qu'à l'heure locale de réception de la requête HTTP.
Architecture recommandée : Découplage Réception et Traitement
Dans une application à forte exigence de fiabilité, il est déconseillé d'exécuter des tâches longues (génération de documents PDF, synchronisation CRM, envois d'emails complexes) directement dans le cycle synchrone du webhook.
[Stripe] ──► [Route Webhook] ── (1. Valide signature) ──► [Queue / Worker / PgBoss] ──► (3. Traitement)
│
└── (2. Répond 200 OK en < 200ms)
- Validation et persistance rapide : Le handler vérifie la signature, enregistre l'événement brut dans une file de messages (queue, table PostgreSQL d'événements, Redis ou Inngest) et renvoie immédiatement un statut
200 OKà Stripe. - Traitement asynchrone par tâche de fond : Un worker ou une tâche d'arrière-plan dépile les événements, applique les mutations métier avec ses propres politiques de réessai bornées et consigne les preuves techniques.
Ce que GVO observe sur ce sujet
Dans le cadre de l'audit de votre code source, GVO examine les surfaces liées aux flux financiers :
- Ce que GVO peut observer :
- Présence de l'appel
stripe.webhooks.constructEventdans les routes de traitement webhook ; - Préservation du corps brut de la requête (raw body) avant validation ;
- Détection des fonctions de réessai non bornées (unbounded retries) ou des boucles asynchrones risquant d'épuiser les ressources du serveur ;
- Détection des erreurs critiques avalées silencieusement (
catchvide sans relance ni notification) dans les flux de mutation financière.
- Présence de l'appel
- Ce que GVO ne peut pas garantir :
- Une analyse statique ne valide pas que le secret configuré dans votre interface d'hébergement correspond exactement au secret configuré dans votre dashboard Stripe ;
- GVO ne peut pas tester en direct la connectivité réseau entre les serveurs de Stripe et votre infrastructure de production.
Sources et références
À poursuivre
Vérifier ce point sur votre projet
GVO confronte le code et les configurations de votre application aux règles de sécurité réelles.