WebhookToolkit
Stripe

Signature webhook Stripe invalide — trouvez pourquoi

Dernière mise à jour : juillet 2026

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.

Tout s'exécute dans votre navigateur avec l'API Web Crypto. Votre secret de signature n'est jamais envoyé à nos serveurs — vérifiez-le dans l'onglet Réseau.

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 :

Un vrai en-tête Stripe-Signature
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.

#CauseSymptôme distinctifCorrectif
1Le 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.
2Mauvais secret de signatureTous 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.
3Un middleware a parsé le corps en premierMarche 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.
4Secret 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.
5En-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.
6Secret du mauvais endpointUn 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.

FrameworkPourquoi le corps est modifiéLe correctif
Expressexpress.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 RouterLes route handlers ne parsent pas le corps pour vous.const body = await request.text() — cette chaîne est déjà brute.
Next.js Pages RouterLe bodyParser intégré réécrit le corps.export const config = { api: { bodyParser: false } }, puis bufferisez le flux.
Flaskrequest.json / request.form consomment et ré-encodent le flux.payload = request.get_data() — des octets, avant de toucher .json.
DjangoAccéder à request.POST verrouille le corps.request.body — lisez-le en premier, avant tout accès au formulaire.
AWS Lambda / API GatewayAPI 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.
Express — corps brut sur la seule route webhook
// 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 reste
Next.js App Router — app/api/webhook/route.ts
export 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 });
}
Next.js Pages Router — désactivez le body parser
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.

Générer un événement Stripe signé

Checklist de diagnostic

Déroulez-les dans l'ordre la prochaine fois que constructEvent lève une erreur.

  1. 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.
  2. Vérifiez que le corps qui atteint constructEvent est la chaîne brute intacte, pas un objet parsé puis ré-sérialisé.
  3. Copiez le secret de signature directement depuis la page de l'endpoint dans le Dashboard — pas depuis stripe listen, ni depuis un autre endpoint.
  4. Vérifiez que vous utilisez le secret live pour le trafic live et le secret test pour le trafic test.
  5. Loggez req.headers['stripe-signature'] et confirmez qu'il est présent et non tronqué par un proxy.
  6. 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.

Stripe webhook testerValidateur de signature webhookRelay

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.

Signature webhook Stripe invalide : les 6 causes · Webhook Toolkit