Sur une architecture WordPress headless, le site public n’est pas WordPress lui-même mais une application front (souvent en Next.js ou Nuxt) qui consomme l’API REST ou GraphQL de WordPress comme backend de contenu et de commerce. Quand Stripe doit notifier le site d’un paiement confirmé, l’endpoint webhook le plus simple à exposer est souvent celui du frontend découplé, hébergé sur une plateforme comme Vercel ou Netlify, plutôt que celui de WordPress directement, en particulier quand WordPress reste derrière un pare-feu applicatif restrictif.
Ce choix d’architecture introduit une étape supplémentaire par rapport à un site WordPress classique où WooCommerce reçoit directement le webhook Stripe et vérifie sa signature nativement : le frontend headless reçoit l’événement en premier, puis doit le retransmettre à WordPress via un appel à l’API REST pour que la commande soit marquée comme payée. Ce tutoriel détaille comment sécuriser cette chaîne en deux maillons, pour qu’aucun des deux étages ne devienne un point d’entrée pour de faux événements de paiement.
Étape 1 : recevoir le webhook et vérifier sa signature en premier
La première règle, non négociable, est que la vérification de la signature Stripe doit avoir lieu avant toute autre action, y compris avant de parser le corps de la requête en JSON pour l’inspecter. Stripe signe chaque webhook avec une clé secrète propre à l’endpoint (whsec_...), générée depuis le tableau de bord Stripe au moment de la création du point de terminaison. Sur le frontend Next.js, la route API dédiée ressemble à ceci :
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(request) {
const payload = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (err) {
return new Response('Signature invalide', { status: 400 });
}
// À partir d'ici seulement, l'événement est considéré comme authentique.
if (event.type === 'checkout.session.completed') {
await transmettreAWordPress(event.data.object);
}
return new Response('ok', { status: 200 });
}
La fonction stripe.webhooks.constructEvent recalcule elle-même la signature HMAC attendue à partir du corps brut de la requête et compare le résultat à celle envoyée par Stripe dans l’en-tête Stripe-Signature, en tenant compte d’une tolérance d’horodatage de trois cents secondes par défaut pour se prémunir contre un rejeu tardif de la requête. Toute erreur à cette étape doit interrompre immédiatement le traitement, sans jamais atteindre l’appel vers WordPress.
Étape 2 : ne jamais faire confiance au corps de la requête à l’arrivée sur WordPress

Une fois la signature validée côté frontend, l’événement doit être retransmis à WordPress. C’est cette deuxième étape qui expose le vrai risque propre à l’architecture headless : si l’endpoint REST de WordPress qui reçoit cette retransmission n’est protégé que par son URL, jamais communiquée publiquement, n’importe qui découvrant cette URL pourrait forger une requête similaire directement vers WordPress, en contournant totalement le frontend et sa vérification de signature Stripe.
La protection consiste à faire signer, cette fois par le frontend lui-même, la requête envoyée vers WordPress, avec un secret partagé distinct de la clé Stripe :
import crypto from 'crypto';
async function transmettreAWordPress(session) {
const corps = JSON.stringify({
order_id: session.metadata.order_id,
payment_intent: session.payment_intent,
});
const signature = crypto
.createHmac('sha256', process.env.SECRET_PARTAGE_WORDPRESS)
.update(corps)
.digest('hex');
await fetch('https://exemple.fr/wp-json/monsite/v1/paiement-confirme', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: corps,
});
}
Étape 3 : vérifier cette signature côté WordPress avant toute écriture
Côté WordPress, la route REST personnalisée vérifie cette signature avant de marquer quoi que ce soit comme payé, exactement selon le même principe que la vérification faite côté frontend pour Stripe :
register_rest_route( 'monsite/v1', '/paiement-confirme', array(
'methods' => 'POST',
'callback' => 'monsite_confirmer_paiement',
'permission_callback' => function( $request ) {
$signature_recue = $request->get_header( 'x_signature' );
$corps = $request->get_body();
$signature_attendue = hash_hmac( 'sha256', $corps, MONSITE_SECRET_PARTAGE );
return hash_equals( $signature_attendue, $signature_recue );
},
) );
L’usage de hash_equals plutôt qu’une comparaison directe avec === n’est pas cosmétique : cette fonction compare les deux chaînes en temps constant, ce qui empêche un attaquant de déduire progressivement la signature attendue par une attaque temporelle, en mesurant les micro-différences de temps de réponse selon le nombre de caractères corrects devinés.
Étape 4 : rendre le traitement idempotent
Stripe peut retransmettre le même webhook plusieurs fois en cas de doute sur la bonne réception, ce qui signifie que la route WordPress peut recevoir deux fois le même événement de paiement confirmé. Le traitement doit donc vérifier l’état actuel de la commande avant d’agir, plutôt que de supposer qu’un seul appel aura lieu :
- Vérifier que la commande n’est pas déjà marquée comme payée avant de déclencher les actions associées (email de confirmation, décrément de stock).
- Stocker l’identifiant
payment_intenttraité, pour ignorer silencieusement un doublon plutôt que de renvoyer une erreur qui inquiéterait Stripe inutilement.
Dans une architecture headless, chaque relais entre deux systèmes est une occasion pour un attaquant de forger une requête à l’étape la moins surveillée. Signer chaque maillon, pas seulement le premier.
Étape 5 : ce qu’il faut vérifier avant la mise en production
Avant de considérer cette chaîne comme prête, une dernière série de vérifications s’impose : confirmer que le secret partagé entre le frontend et WordPress est bien différent du secret webhook Stripe, stocker les deux dans des variables d’environnement jamais versionnées, tester explicitement l’envoi d’une requête sans signature ou avec une signature erronée vers l’endpoint WordPress pour confirmer le rejet, et documenter, côté frontend, le comportement attendu en cas d’échec de la retransmission vers WordPress (file d’attente, nouvelle tentative, alerte).