Stripe-webhook: handtekeningverificatie mislukt — ontdek waarom
constructEvent van Stripe gooit No signatures found matching the expected signature for payload en je hebt geen idee welke van een dozijn dingen er misging. Deze pagina doet één ding: ze neemt de drie artefacten die Stripe je geeft — het whsec_-secret, de exacte ruwe body en de Stripe-Signature-header — berekent de HMAC opnieuw en wijst de ene oorzaak aan die het echt brak.
Kort gezegd: in 9 van de 10 gevallen is het de ruwe body (je framework heeft de JSON vóór de verificatie opnieuw geserialiseerd) of het verkeerde signing-secret (het whsec_ van de CLI in plaats van dat van het endpoint). De debugger hieronder onderscheidt die twee met één keer plakken.
Debug je Stripe-handtekening
Stripe is standaard geselecteerd. De HMAC wordt in je browser berekend met WebCrypto — je whsec_ verlaat het tabblad nooit.
Anatomie van de Stripe-Signature-header
Elke Stripe-webhook komt binnen met één header die er zo uitziet. Elk veld tussen de komma's heeft een taak:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomie van de Stripe-Signature-header | |
|---|---|
| t= | Unix-timestamp (seconden) van het moment waarop Stripe de handtekening genereerde. Hij maakt deel uit van wat ondertekend wordt — en is waar de replaycontrole van 5 minuten mee vergelijkt. |
| v1= | De handtekening die je echt verifieert: HMAC-SHA256 van de string t + "." + rawBody, hex-gecodeerd, met het whsec_ van je endpoint als sleutel. Dit is het enige scheme dat constructEvent controleert. |
| v0= | Een verouderd testscheme. Stripe stuurt het nog mee, maar de library negeert het bij de verificatie — probeer niet tegen v0 te matchen. |
| , (komma) | Tijdens een secretrotatie ondertekent Stripe met zowel het oude als het nieuwe secret, dus je kunt twee v1=-waarden zien. constructEvent slaagt zodra er één matcht. |
De ondertekende string is `${t}.${rawBody}` — niet alleen de body. Vergeet je het voorvoegsel t. in een zelfgebouwde verifier, dan faalt elke handtekening, zelfs met het juiste secret.
De 6 hoofdoorzaken, in de volgorde waarin je ze checkt
Gerangschikt naar hoe vaak ze de boosdoener zijn. Elke oorzaak heeft een kenmerk dat haar van de andere onderscheidt.
| # | Oorzaak | Onderscheidend symptoom | Oplossing |
|---|---|---|---|
| 1 | Body is niet de ruwe bytes | Faalt bij echte events, maar de berekende HMAC komt in de buurt; slaagt als je de exacte ruwe string in de debugger hierboven plakt. | Geef constructEvent de onaangeroerde request-body, voordat een JSON-parser eraan zit. |
| 2 | Verkeerd signing-secret | Elk event faalt, de HMAC lijkt in de verste verte niet op de v1. Vaak het whsec_ van de CLI gebruikt tegen verkeer uit het dashboard. | Kopieer het secret van de pagina van het endpoint in het Dashboard (Developers → Webhooks), niet uit stripe listen. |
| 3 | Middleware parste de body eerst | Werkt met stripe trigger maar geeft 400 in productie; Express app.use(express.json()) draait vóór je route. | Mount express.raw({type:'application/json'}) alleen op de webhookroute, boven de globale JSON-parser. |
| 4 | Secret van testmodus in livemodus (of omgekeerd) | De handtekening faalt alleen bij echte betalingen en slaagt bij test-events. Het whsec_ lijkt goed maar hoort bij de andere modus. | Gebruik het whsec_ van het live-endpoint voor liveverkeer; test en live hebben elk hun eigen secret. |
| 5 | Header ontbreekt of is afgekapt | sig is undefined of leeg; een proxy of CDN heeft Stripe-Signature verwijderd of in kleine letters gezet. | Lees req.headers['stripe-signature'] (kleine letters in Node) en zorg dat je proxy hem doorstuurt. |
| 6 | Secret van het verkeerde endpoint | Het ene endpoint verifieert, het andere geeft 400 — je hebt één whsec_ hergebruikt voor twee endpoint-URL's. | Elk endpoint heeft zijn eigen signing-secret. Koppel het secret aan de URL die Stripe echt aanroept. |
De fix voor de ruwe body, per framework
Oorzaak #1 is bijna altijd deze: je webframework heeft de JSON gelezen, geparst en opnieuw geserialiseerd voordat constructEvent hem zag. Opnieuw geserialiseerde JSON heeft andere witruimte, dus de HMAC matcht niet meer. Zo geef je elk framework de onaangeroerde bytes.
| Framework | Waarom de body wordt gewijzigd | De fix |
|---|---|---|
| Express | express.json() parst de ruwe tekst en gooit hem app-breed weg. | express.raw({type:'application/json'}) op de webhookroute, vóór express.json(). |
| Next.js App Router | Route handlers parsen de body niet voor je. | const body = await request.text() — die string is al ruw. |
| Next.js Pages Router | De ingebouwde bodyParser herschrijft de body. | export const config = { api: { bodyParser: false } }, en buffer daarna de stream. |
| Flask | request.json / request.form consumeren de stream en coderen hem opnieuw. | payload = request.get_data() — bytes, voordat je .json aanraakt. |
| Django | Toegang tot request.POST vergrendelt de body. | request.body — lees die eerst, vóór enige toegang tot het formulier. |
| AWS Lambda / API Gateway | API Gateway kan base64-encoderen, of een mapping template kan de body herschrijven. | Gebruik Lambda proxy integration; als event.isBase64Encoded, decodeer dan event.body vóór het verifiëren. |
// De webhookroute MOET vóór express.json() komen
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()); // al het andereexport async function POST(req: Request) {
const body = await req.text(); // ruw, onaangeroerd
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 });
}Plak de exacte bytes in de debugger hierboven: als de handtekening pas matcht nadat je een afsluitend regeleinde of een opnieuw ge-encodeerd veld verwijdert, is de body onderweg gewijzigd — niet je secret.
Testmodus vs. livemodus: het secret dat goed lijkt maar het niet is
Stripe geeft je drie verschillende whsec_-waarden en die zijn makkelijk te verwarren. Ten eerste het secret van het test-endpoint, dat je in het Dashboard ziet zolang je in testmodus zit. Ten tweede het secret van het live-endpoint — een aparte waarde op hetzelfde endpoint, omgezet naar live. Ten derde het CLI-secret dat stripe listen print, weer anders en alleen geldig voor events die door die sessie worden doorgestuurd. Alle drie leveren een geldig ogende HMAC op, dus de fout is steeds dezelfde generieke No signatures found — nergens staat verkeerde modus. Het kenmerk: testbetalingen verifiëren, echte betalingen geven 400. Oplossing: lees het secret uit precies het endpoint, in precies de modus, dat het verkeer ontvangt.
Het replayvenster van 5 minuten dat iedereen vergeet
constructEvent controleert niet alleen de HMAC — het weigert elk event waarvan de t=-timestamp meer dan 300 seconden oud is, met de fout Timestamp outside the tolerance zone. Dat breekt je op wanneer je een gevangen event uren later opnieuw afspeelt, of wanneer de klok van je server is gaan afwijken. De handtekening is volkomen geldig; het is de leeftijd die faalt. De debugger hierboven toont de leeftijd van de timestamp, zodat je een verouderde replay kunt onderscheiden van een echte handtekeningmismatch. Heb je echt een langer venster nodig, dan accepteert constructEvent een vierde argument tolerance in seconden — maar fix eerst de klok.
Ligt het aan je handler of aan je secret? Genereer een event waarvan je weet dat het klopt
Als de debugger zegt dat het secret klopt maar productie nog steeds 400 geeft, zit de bug in hoe je handler de body leest. Isoleer het: onze Signer genereert een payload met een Stripe-handtekening op basis van een secret dat jij beheert, zodat je een gegarandeerd geldig event rechtstreeks naar je lokale handler kunt POSTen. Slaagt het, dan is je verificatiecode in orde en ligt het probleem bij het secret of de middleware. Faalt het, dan wijzigt de handler de body — ga terug naar de tabel over de ruwe body hierboven.
Diagnostische checklist
Loop deze in volgorde af de volgende keer dat constructEvent een fout gooit.
- Plak het falende whsec_, de ruwe body en Stripe-Signature in de debugger hierboven — hij benoemt de oorzaak voordat je verder leest.
- Controleer dat de body die constructEvent bereikt de onaangeroerde ruwe string is, geen geparst en opnieuw geserialiseerd object.
- Kopieer het signing-secret rechtstreeks van de pagina van het endpoint in het Dashboard — niet uit stripe listen, niet van een ander endpoint.
- Controleer dat je het live-secret gebruikt voor liveverkeer en het test-secret voor testverkeer.
- Log req.headers['stripe-signature'] en controleer dat hij aanwezig is en niet door een proxy is afgekapt.
- Controleer dat de t=-timestamp van het event niet ouder is dan 5 minuten (verouderde replay of klokafwijking).
Vang het echte falende event af en speel het opnieuw af
Kom je er nog steeds niet uit? Richt Stripe op een Webhook Toolkit-Relay of een capture-URL, vang precies de request die faalt en inspecteer de ruwe body en headers byte voor byte — speel hem daarna zo vaak als nodig opnieuw af op je handler, zonder op een volgend live-event te wachten.
Veelgestelde vragen
Waarom werkt mijn Stripe-webhook lokaal maar niet in productie?
Bijna altijd de ruwe body. Lokaal met stripe listen stuurt de CLI onaangeroerde bytes door, maar je productie-middleware (Express express.json(), een bodyParser van de Pages Router, een mapping template van API Gateway) parst de JSON en serialiseert hem opnieuw vóór de verificatie. Geef constructEvent de ruwe request-body en het slaagt.
Wat betekent “No signatures found matching the expected signature for payload”?
De HMAC die je berekende, komt met geen enkele v1= overeen die Stripe stuurde. Twee gebruikelijke oorzaken: de body is vóór de verificatie gewijzigd, of het signing-secret is fout (vaak het whsec_ van de CLI in plaats van dat van het endpoint). Plak alle drie in de debugger hierboven en hij vertelt je welke.
Kan ik hetzelfde signing-secret gebruiken voor test- en livemodus?
Nee. Elk endpoint heeft een apart whsec_ voor test en voor live, en de CLI print een derde. Een test-secret levert een geldig ogende HMAC op die bij live-events toch faalt, met dezelfde generieke fout. Stem het secret af op de modus die het verkeer ontvangt.
Hoe los ik “Timestamp outside the tolerance zone” op?
De handtekening is geldig, maar de t= van het event is meer dan 300 seconden oud — meestal een opnieuw afgespeelde capture of een afwijkende serverklok. Fix de klok of speel een vers event af. Als laatste redmiddel geef je een grotere tolerance (in seconden) mee als vierde argument van constructEvent.
Wordt v0 in de Stripe-Signature-header gebruikt voor verificatie?
Nee. Alleen v1= (HMAC-SHA256 van t.rawBody) wordt door constructEvent gecontroleerd. v0= is een verouderd testscheme dat Stripe nog meestuurt maar dat de library negeert. Matcht je zelfgebouwde verifier tegen v0, dan is dat je bug.
Stuurt deze Stripe-handtekeningdebugger mijn secret ergens naartoe?
Nee. De HMAC wordt in je browser berekend met WebCrypto. Je whsec_, body en header verlaten het tabblad nooit — er wordt niets geüpload, gelogd of opgeslagen. Verbreek je netwerkverbinding en het werkt nog steeds.