Verifica della firma webhook Stripe fallita — scopri perché
constructEvent di Stripe lancia No signatures found matching the expected signature for payload e non hai idea di quale tra una dozzina di cose sia andata storta. Questa pagina fa una cosa sola: prende i tre elementi che Stripe ti fornisce — il secret whsec_, il body grezzo esatto e l'header Stripe-Signature — ricalcola l'HMAC e indica l'unica causa che l'ha davvero rotta.
In breve: 9 volte su 10 è il body grezzo (il tuo framework ha ri-serializzato il JSON prima della verifica) o il secret di firma sbagliato (il whsec_ della CLI invece di quello dell'endpoint). Il debugger qui sotto distingue i due casi con un solo incolla.
Fai il debug della tua firma Stripe
Stripe è selezionato di default. L'HMAC viene calcolato nel tuo browser con WebCrypto — il tuo whsec_ non lascia mai la scheda.
Anatomia dell'header Stripe-Signature
Ogni webhook Stripe arriva con un header che assomiglia a questo. Ogni campo separato da virgola ha un compito:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomia dell'header Stripe-Signature | |
|---|---|
| t= | Timestamp Unix (in secondi) del momento in cui Stripe ha generato la firma. Fa parte di ciò che viene firmato — ed è il valore con cui si confronta il controllo anti-replay di 5 minuti. |
| v1= | La firma che verifichi davvero: HMAC-SHA256 della stringa t + "." + rawBody, codificato in hex, con chiave il whsec_ del tuo endpoint. È l'unico schema che constructEvent controlla. |
| v0= | Uno schema di test legacy. Stripe lo invia ancora ma la libreria lo ignora in verifica — non provare a confrontarti con v0. |
| , (virgola) | Durante una rotazione del secret Stripe firma sia con il vecchio sia con il nuovo secret, quindi puoi vedere due valori v1=. constructEvent passa se almeno uno corrisponde. |
La stringa firmata è `${t}.${rawBody}` — non il body da solo. Dimentica il prefisso t. in un verificatore scritto a mano e ogni firma fallisce, anche con il secret giusto.
Le 6 cause alla radice, nell'ordine in cui controllarle
Ordinate per frequenza. Ognuna ha un indizio che la distingue dalle altre.
| # | Causa | Sintomo distintivo | Soluzione |
|---|---|---|---|
| 1 | Il body non sono i byte grezzi | Fallisce sugli eventi reali ma l'HMAC calcolato è vicino; passa se incolli la stringa grezza esatta nel debugger qui sopra. | Passa a constructEvent il body della richiesta intatto, prima che qualsiasi parser JSON lo tocchi. |
| 2 | Secret di firma sbagliato | Ogni evento fallisce, HMAC lontanissimo dal v1. Spesso è il whsec_ della CLI usato sul traffico della dashboard. | Copia il secret dalla pagina dell'endpoint nella Dashboard (Developers → Webhooks), non da stripe listen. |
| 3 | Un middleware ha parsato il body prima | Funziona con stripe trigger ma dà 400 in produzione; app.use(express.json()) di Express gira prima della tua route. | Monta express.raw({type:'application/json'}) solo sulla route del webhook, sopra il parser JSON globale. |
| 4 | Secret di test in modalità live (o viceversa) | La firma fallisce solo per i pagamenti reali, passa per gli eventi di test. Il whsec_ sembra giusto ma appartiene all'altra modalità. | Usa il whsec_ dell'endpoint live per il traffico live; test e live hanno ciascuno il proprio. |
| 5 | Header mancante o troncato | sig è undefined o vuoto; un proxy o una CDN ha rimosso Stripe-Signature o l'ha messo in minuscolo. | Leggi req.headers['stripe-signature'] (in minuscolo in Node) e assicurati che il tuo proxy lo inoltri. |
| 6 | Secret dell'endpoint sbagliato | Un endpoint verifica, un altro dà 400 — hai riusato un solo whsec_ su due URL di endpoint. | Ogni endpoint ha il proprio secret di firma. Associa il secret all'URL che Stripe sta effettivamente chiamando. |
La correzione del body grezzo, framework per framework
La causa n. 1 è quasi sempre questa: il tuo framework web ha letto, parsato e ri-serializzato il JSON prima che constructEvent lo vedesse. Il JSON ri-serializzato ha spazi diversi, quindi l'HMAC non corrisponde più. Ecco come passare a ogni framework i byte intatti.
| Framework | Perché il body viene modificato | La correzione |
|---|---|---|
| Express | express.json() parsa e scarta il testo grezzo in tutta l'app. | express.raw({type:'application/json'}) sulla route del webhook, prima di express.json(). |
| Next.js App Router | I route handler non parsano il body al posto tuo. | const body = await request.text() — quella stringa è già grezza. |
| Next.js Pages Router | Il bodyParser integrato riscrive il body. | export const config = { api: { bodyParser: false } }, poi bufferizza lo stream. |
| Flask | request.json / request.form consumano e ricodificano lo stream. | payload = request.get_data() — byte, prima di toccare .json. |
| Django | Accedere a request.POST blocca il body. | request.body — leggilo per primo, prima di qualsiasi accesso al form. |
| AWS Lambda / API Gateway | API Gateway può codificare in base64, o un mapping template può riscrivere il body. | Usa la Lambda proxy integration; se event.isBase64Encoded, decodifica event.body prima di verificare. |
// La route del webhook DEVE venire prima di 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()); // tutto il restoexport async function POST(req: Request) {
const body = await req.text(); // grezzo, intatto
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 });
}Incolla i byte esatti nel debugger qui sopra: se la firma corrisponde solo dopo aver tolto un a capo finale o un campo ricodificato, il body è stato modificato in transito — non è il tuo secret.
Test mode vs live mode: il secret che sembra giusto ma non lo è
Stripe ti dà tre valori whsec_ diversi ed è facile confonderli. Primo, il secret dell'endpoint di test, mostrato nella Dashboard quando sei in test mode. Secondo, il secret dell'endpoint live — un valore separato sullo stesso endpoint passato in live. Terzo, il secret della CLI che stampa stripe listen, ancora diverso e valido solo per gli eventi inoltrati da quella sessione. Tutti e tre producono un HMAC dall'aspetto valido, quindi l'errore è sempre il generico No signatures found — niente dice modalità sbagliata. L'indizio: i pagamenti di test si verificano, quelli reali danno 400. Soluzione: leggi il secret dall'endpoint esatto, nella modalità esatta, che riceve il traffico.
La finestra anti-replay di 5 minuti che tutti dimenticano
constructEvent non controlla solo l'HMAC — rifiuta qualsiasi evento il cui timestamp t= abbia più di 300 secondi, lanciando Timestamp outside the tolerance zone. Ti colpisce quando rinvii un evento catturato ore dopo, o quando l'orologio del tuo server è sfasato. La firma è perfettamente valida; è l'età a far fallire il controllo. Il debugger qui sopra mostra l'età del timestamp, così distingui un replay scaduto da una vera discrepanza di firma. Se ti serve davvero una finestra più lunga, constructEvent accetta un quarto argomento tolerance in secondi — ma prima sistema l'orologio.
È il tuo handler o il tuo secret? Genera un evento sicuramente valido
Quando il debugger dice che il secret è giusto ma la produzione dà ancora 400, il bug sta nel modo in cui il tuo handler legge il body. Isolalo: il nostro Signer genera un payload firmato Stripe con un secret che controlli tu, così puoi inviare in POST un evento sicuramente valido direttamente al tuo handler locale. Se passa, il tuo codice di verifica è a posto e il problema è il secret o il middleware. Se fallisce, l'handler sta modificando il body — torna alla tabella del body grezzo qui sopra.
Checklist di diagnosi
Esegui questi controlli in ordine la prossima volta che constructEvent lancia un'eccezione.
- Incolla nel debugger qui sopra il whsec_, il body grezzo e lo Stripe-Signature che falliscono — ti dà la causa prima che tu legga oltre.
- Verifica che il body che arriva a constructEvent sia la stringa grezza intatta, non un oggetto parsato e ri-serializzato.
- Copia il secret di firma direttamente dalla pagina dell'endpoint nella Dashboard — non da stripe listen, non da un altro endpoint.
- Verifica di usare il secret live per il traffico live e il secret di test per il traffico di test.
- Logga req.headers['stripe-signature'] e verifica che sia presente e non troncato da un proxy.
- Verifica che il timestamp t= dell'evento non abbia più di 5 minuti (replay scaduto o orologio sfasato).
Cattura e riesegui il vero evento che fallisce
Ancora bloccato? Punta Stripe su un Relay o su un URL di cattura di Webhook Toolkit, intercetta la richiesta esatta che fallisce e ispeziona body grezzo e header byte per byte — poi rieseguila sul tuo handler tutte le volte che vuoi, senza aspettare un altro evento live.
Domande frequenti
Perché il mio webhook Stripe funziona in locale ma non in produzione?
Quasi sempre è il body grezzo. In locale con stripe listen la CLI inoltra i byte intatti, ma il tuo middleware di produzione (express.json() di Express, il bodyParser del Pages Router, un mapping template di API Gateway) parsa e ri-serializza il JSON prima della verifica. Passa a constructEvent il body grezzo della richiesta e la verifica va a buon fine.
Cosa significa «No signatures found matching the expected signature for payload»?
L'HMAC che hai calcolato non corrisponde a nessun v1= inviato da Stripe. Le due cause abituali: il body è stato modificato prima della verifica, oppure il secret di firma è sbagliato (spesso il whsec_ della CLI invece di quello dell'endpoint). Incolla tutti e tre gli elementi nel debugger qui sopra e ti dice quale.
Posso usare lo stesso secret di firma in test mode e in live mode?
No. Ogni endpoint ha un whsec_ separato per test e per live, e la CLI ne stampa un terzo. Un secret di test produce un HMAC dall'aspetto valido che fallisce comunque sugli eventi live, con lo stesso errore generico. Abbina il secret alla modalità che riceve il traffico.
Come risolvo «Timestamp outside the tolerance zone»?
La firma è valida ma il t= dell'evento ha più di 300 secondi — di solito un evento catturato e rinviato o un orologio del server sfasato. Sistema l'orologio, oppure rinvia un evento nuovo. Come ultima risorsa, passa una tolerance più ampia (in secondi) come quarto argomento di constructEvent.
Il v0 nell'header Stripe-Signature viene usato per la verifica?
No. constructEvent controlla solo v1= (HMAC-SHA256 di t.rawBody). v0= è uno schema di test legacy che Stripe emette ancora ma che la libreria ignora. Se il tuo verificatore scritto a mano confronta con v0, il bug è lì.
Questo debugger di firme Stripe invia il mio secret da qualche parte?
No. L'HMAC viene calcolato nel tuo browser con WebCrypto. Il tuo whsec_, il body e l'header non lasciano mai la scheda — nulla viene caricato, registrato o salvato. Scollegati dalla rete e funziona lo stesso.