Signature webhook Stripe invalide — trouvez pourquoi
Le constructEvent de Stripe renvoie No signatures found matching the expected signature for payload et vous ne savez pas laquelle des dix causes possibles a échoué. Cette page fait une seule chose : elle prend les trois éléments que Stripe vous donne — le secret whsec_, le corps brut exact et l'en-tête Stripe-Signature — recalcule le HMAC, et pointe la cause qui a réellement échoué.
En résumé : 9 fois sur 10 c'est le corps brut (votre framework a ré-sérialisé le JSON avant la vérification) ou le mauvais secret de signature (le whsec_ du CLI au lieu de celui de l'endpoint). Le débogueur ci-dessous distingue les deux en un collage.
Déboguez votre signature Stripe
Stripe est sélectionné par défaut. Le HMAC est calculé dans votre navigateur avec WebCrypto — votre whsec_ ne quitte jamais l'onglet.
Anatomie de l'en-tête Stripe-Signature
Chaque webhook Stripe arrive avec un en-tête de cette forme. Chaque champ séparé par une virgule a un rôle :
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomie de l'en-tête Stripe-Signature | |
|---|---|
| t= | Horodatage Unix (secondes) au moment où Stripe a généré la signature. Il fait partie de ce qui est signé — et de ce que compare le contrôle de rejeu de 5 minutes. |
| v1= | La signature que vous vérifiez réellement : HMAC-SHA256 de la chaîne t + "." + corpsBrut, encodée en hexadécimal, avec le whsec_ de votre endpoint comme clé. C'est le seul schéma que constructEvent vérifie. |
| v0= | Un schéma de test hérité. Stripe l'envoie encore mais la librairie l'ignore pour la vérification — n'essayez pas de le comparer à v0. |
| , (virgule) | Pendant une rotation de secret, Stripe signe avec l'ancien et le nouveau secret : vous pouvez voir deux valeurs v1=. constructEvent réussit si l'une correspond. |
La chaîne signée est `${t}.${corpsBrut}` — pas le corps seul. Oubliez le préfixe t. dans un vérificateur maison et toutes les signatures échouent, même avec le bon secret.
Les 6 causes racines, dans l'ordre à vérifier
Classées par fréquence. Chacune a un symptôme qui la distingue des autres.
| # | Cause | Symptôme distinctif | Correctif |
|---|---|---|---|
| 1 | Le corps n'est pas les octets bruts | Échoue sur les vrais événements mais le HMAC calculé est proche ; réussit si vous collez la chaîne brute exacte dans le débogueur ci-dessus. | Passez à constructEvent le corps de requête intact, avant tout parseur JSON. |
| 2 | Mauvais secret de signature | Tous les événements échouent, HMAC très éloigné du v1. Souvent le whsec_ du CLI utilisé sur le trafic du Dashboard. | Copiez le secret depuis la page de l'endpoint dans le Dashboard (Developers → Webhooks), pas depuis stripe listen. |
| 3 | Un middleware a parsé le corps en premier | Marche avec stripe trigger mais renvoie 400 en prod ; Express app.use(express.json()) s'exécute avant votre route. | Montez express.raw({type:'application/json'}) sur la seule route webhook, au-dessus du parseur JSON global. |
| 4 | Secret de test en mode live (ou l'inverse) | La signature échoue seulement pour les vrais paiements, réussit pour les événements de test. Le whsec_ a l'air bon mais appartient à l'autre mode. | Utilisez le whsec_ de l'endpoint live pour le trafic live ; test et live ont chacun le leur. |
| 5 | En-tête absent ou tronqué | sig est undefined ou vide ; un proxy ou CDN a retiré Stripe-Signature ou l'a mis en minuscules. | Lisez req.headers['stripe-signature'] (minuscules en Node), et assurez-vous que votre proxy le transmet. |
| 6 | Secret du mauvais endpoint | Un endpoint vérifie, un autre renvoie 400 — vous avez réutilisé un seul whsec_ sur deux URLs d'endpoint. | Chaque endpoint a son propre secret. Associez le secret à l'URL que Stripe appelle réellement. |
Le correctif du corps brut, framework par framework
La cause n°1, c'est presque toujours ça : votre framework a lu, parsé et ré-sérialisé le JSON avant que constructEvent ne le voie. Le JSON ré-sérialisé a des espaces différents, donc le HMAC ne correspond plus. Voici comment donner à chaque framework les octets intacts.
| Framework | Pourquoi le corps est modifié | Le correctif |
|---|---|---|
| Express | express.json() parse et jette le texte brut pour toute l'app. | express.raw({type:'application/json'}) sur la route webhook, avant express.json(). |
| Next.js App Router | Les route handlers ne parsent pas le corps pour vous. | const body = await request.text() — cette chaîne est déjà brute. |
| Next.js Pages Router | Le bodyParser intégré réécrit le corps. | export const config = { api: { bodyParser: false } }, puis bufferisez le flux. |
| Flask | request.json / request.form consomment et ré-encodent le flux. | payload = request.get_data() — des octets, avant de toucher .json. |
| Django | Accéder à request.POST verrouille le corps. | request.body — lisez-le en premier, avant tout accès au formulaire. |
| AWS Lambda / API Gateway | API Gateway peut encoder en base64, ou un mapping template réécrire le corps. | Utilisez l'intégration proxy Lambda ; si event.isBase64Encoded, décodez event.body avant de vérifier. |
// La route webhook DOIT précéder express.json()
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}
);
app.use(express.json()); // tout le resteexport async function POST(req: Request) {
const body = await req.text(); // brut, intact
const sig = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
return Response.json({ received: true });
}import { buffer } from 'micro';
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
const raw = await buffer(req);
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}Collez les octets exacts dans le débogueur ci-dessus : si la signature ne correspond qu'après avoir retiré un saut de ligne final ou un champ ré-encodé, c'est que le corps a été modifié en route — pas votre secret.
Mode test vs mode live : le secret qui a l'air bon mais ne l'est pas
Stripe vous donne trois valeurs whsec_ différentes, faciles à confondre. D'abord, le secret de l'endpoint en test, affiché dans le Dashboard en mode test. Ensuite, le secret de l'endpoint en live — une valeur distincte sur le même endpoint passé en live. Enfin, le secret du CLI qu'affiche stripe listen, encore différent et valable seulement pour les événements transmis par cette session. Les trois produisent un HMAC d'apparence valide, donc l'erreur reste le générique No signatures found — rien n'indique une erreur de mode. Le symptôme : les paiements de test passent, les vrais renvoient 400. Correctif : prenez le secret de l'endpoint exact, dans le mode exact, qui reçoit le trafic.
La fenêtre de rejeu de 5 minutes que tout le monde oublie
constructEvent ne vérifie pas que le HMAC — il rejette tout événement dont l'horodatage t= dépasse 300 secondes, en levant Timestamp outside the tolerance zone. Ça mord quand vous rejouez un événement capturé des heures plus tard, ou quand l'horloge serveur a dérivé. La signature est parfaitement valide ; c'est l'âge qui échoue. Le débogueur ci-dessus affiche l'âge de l'horodatage pour distinguer un rejeu périmé d'une vraie erreur de signature. Si vous avez vraiment besoin d'une fenêtre plus large, constructEvent accepte un quatrième argument tolerance en secondes — mais réglez d'abord l'horloge.
C'est votre handler ou votre secret ? Générez un événement valide connu
Quand le débogueur dit que le secret est bon mais que la prod renvoie toujours 400, le bug est dans la façon dont votre handler lit le corps. Isolez-le : notre Signer génère un payload signé Stripe avec un secret que vous contrôlez, pour envoyer un événement garanti valide directement à votre handler local. S'il passe, votre code de vérification est bon et le problème vient du secret ou du middleware. S'il échoue, le handler modifie le corps — revenez au tableau du corps brut ci-dessus.
Checklist de diagnostic
Déroulez-les dans l'ordre la prochaine fois que constructEvent lève une erreur.
- Collez le whsec_, le corps brut et le Stripe-Signature qui échouent dans le débogueur ci-dessus — il nomme la cause avant même que vous lisiez plus loin.
- Vérifiez que le corps qui atteint constructEvent est la chaîne brute intacte, pas un objet parsé puis ré-sérialisé.
- Copiez le secret de signature directement depuis la page de l'endpoint dans le Dashboard — pas depuis stripe listen, ni depuis un autre endpoint.
- Vérifiez que vous utilisez le secret live pour le trafic live et le secret test pour le trafic test.
- Loggez req.headers['stripe-signature'] et confirmez qu'il est présent et non tronqué par un proxy.
- Vérifiez que l'horodatage t= de l'événement n'a pas plus de 5 minutes (rejeu périmé ou dérive d'horloge).
Capturez et rejouez le vrai événement en échec
Toujours bloqué ? Pointez Stripe vers un Relay ou une URL de capture Webhook Toolkit, attrapez la requête exacte qui échoue, et inspectez le corps brut et les en-têtes octet par octet — puis rejouez-la contre votre handler autant de fois que nécessaire, sans attendre un autre événement live.
Questions fréquentes
Pourquoi mon webhook Stripe marche en local mais pas en production ?
Presque toujours le corps brut. En local avec stripe listen, le CLI transmet les octets intacts, mais votre middleware de production (Express express.json(), un bodyParser Pages Router, un mapping template API Gateway) parse et ré-sérialise le JSON avant la vérification. Passez le corps de requête brut à constructEvent et ça passe.
Que signifie 'No signatures found matching the expected signature for payload' ?
Le HMAC que vous avez calculé ne correspond à aucun v1= envoyé par Stripe. Deux causes habituelles : le corps a été modifié avant la vérification, ou le secret de signature est faux (souvent le whsec_ du CLI au lieu de celui de l'endpoint). Collez les trois dans le débogueur ci-dessus, il vous dit lequel.
Puis-je utiliser le même secret de signature pour le mode test et le mode live ?
Non. Chaque endpoint a un whsec_ distinct pour le test et pour le live, et le CLI en affiche un troisième. Un secret de test produit un HMAC d'apparence valide qui échoue quand même sur les événements live, avec la même erreur générique. Associez le secret au mode qui reçoit le trafic.
Comment corriger 'Timestamp outside the tolerance zone' ?
La signature est valide mais le t= de l'événement dépasse 300 secondes — d'ordinaire une capture rejouée ou une horloge serveur qui a dérivé. Réglez l'horloge, ou rejouez un événement frais. En dernier recours, passez une tolerance plus grande (en secondes) en quatrième argument de constructEvent.
Le champ v0 de l'en-tête Stripe-Signature sert-il à la vérification ?
Non. Seul v1= (HMAC-SHA256 de t.corpsBrut) est vérifié par constructEvent. v0= est un schéma de test hérité que Stripe émet encore mais que la librairie ignore. Si votre vérificateur maison compare à v0, c'est votre bug.
Ce débogueur de signature Stripe envoie-t-il mon secret quelque part ?
Non. Le HMAC est calculé dans votre navigateur avec WebCrypto. Votre whsec_, le corps et l'en-tête ne quittent jamais l'onglet — rien n'est envoyé, journalisé ni stocké. Déconnectez le réseau et ça marche encore.