[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"tools-page-webhook-signature-verifier":3},{"page":4,"related":266,"variants":285},{"path":5,"name":6,"title":7,"description":8,"checkedAt":9,"sources":10,"faq":44,"related":63,"nuxter":67,"body":70,"tool":264,"preset":265},"\u002Ftools\u002Fwebhook-signature-verifier","Webhook signature verifier","Webhook Signature Verification for Stripe and Polar","Webhook signature verification in your browser for Stripe, Polar, Standard Webhooks, Lemon Squeezy, Creem, Paddle and GitHub, with the reason a check fails.","2026-10-06",[11,14,17,20,23,26,29,32,35,38,41],{"label":12,"url":13},"Stripe docs, Receive Stripe events in your webhook endpoint","https:\u002F\u002Fdocs.stripe.com\u002Fwebhooks",{"label":15,"url":16},"Stripe docs, Resolve webhook signature verification errors","https:\u002F\u002Fdocs.stripe.com\u002Fwebhooks\u002Fsignature",{"label":18,"url":19},"Stripe docs, API keys","https:\u002F\u002Fdocs.stripe.com\u002Fkeys",{"label":21,"url":22},"Polar docs, Handle and monitor webhook deliveries","https:\u002F\u002Fpolar.sh\u002Fdocs\u002Fintegrate\u002Fwebhooks\u002Fdelivery",{"label":24,"url":25},"Standard Webhooks specification","https:\u002F\u002Fgithub.com\u002Fstandard-webhooks\u002Fstandard-webhooks\u002Fblob\u002Fmain\u002Fspec\u002Fstandard-webhooks.md",{"label":27,"url":28},"Standard Webhooks JavaScript library","https:\u002F\u002Fgithub.com\u002Fstandard-webhooks\u002Fstandard-webhooks\u002Fblob\u002Fmain\u002Flibraries\u002Fjavascript\u002Fsrc\u002Findex.ts",{"label":30,"url":31},"Lemon Squeezy docs, Signing requests","https:\u002F\u002Fdocs.lemonsqueezy.com\u002Fhelp\u002Fwebhooks\u002Fsigning-requests",{"label":33,"url":34},"Creem docs, Webhooks","https:\u002F\u002Fdocs.creem.io\u002Fcode\u002Fwebhooks",{"label":36,"url":37},"Paddle docs, Verify webhook signatures","https:\u002F\u002Fdeveloper.paddle.com\u002Fwebhooks\u002Fsignature-verification",{"label":39,"url":40},"GitHub docs, Validating webhook deliveries","https:\u002F\u002Fdocs.github.com\u002Fen\u002Fwebhooks\u002Fusing-webhooks\u002Fvalidating-webhook-deliveries",{"label":42,"url":43},"h3 docs, Request utils","https:\u002F\u002Fv1.h3.dev\u002Futils\u002Frequest",[45,48,51,54,57,60],{"question":46,"answer":47},"Why does Stripe webhook signature verification fail?","Almost always because of the secret or the body. The secret that stripe listen prints is not the one on your Dashboard endpoint, and test and live mode each have their own. The body must be the exact bytes Stripe sent, so a framework that parses the JSON and writes it out again breaks the check.",{"question":49,"answer":50},"How do I verify a Polar webhook signature?","Polar follows Standard Webhooks, so the signature is an HMAC-SHA256 of the webhook-id, the webhook-timestamp and the raw body joined by dots, in base64. What changes is the key. Secrets made on or after 8 September 2026 are base64 after whsec_, and older ones use the whole whsec_ string as UTF-8. Polar's SDKs from 1.0.0-alpha.19 try both.",{"question":52,"answer":53},"Does Lemon Squeezy include a timestamp in its webhook signature?","No. X-Signature is an HMAC-SHA256 hex digest of the raw body alone, keyed with the signing secret you entered when you created the webhook. Nothing in it dates the delivery, so a captured request verifies again later, and your handler has to ignore work it has already done.",{"question":55,"answer":56},"How does Creem sign its webhooks?","Creem puts an HMAC-SHA256 of the raw body, keyed with your webhook secret and written in hex, in the creem-signature header. It signs no timestamp. It retries failed deliveries, so the same event can arrive twice and your handler should be idempotent.",{"question":58,"answer":59},"Is it safe to paste a webhook secret into this page?","The check runs in this tab with Web Crypto, and the page makes no request with anything you type. Only the provider you pick goes in the address. Use a test-mode secret anyway, and rotate any live secret you have pasted into a website, this one included.",{"question":61,"answer":62},"What is Standard Webhooks?","An open specification for signing webhooks the same way across providers. It sends webhook-id, webhook-timestamp and webhook-signature headers, signs the id, timestamp and body with HMAC-SHA256, and issues secrets as base64 with a whsec_ prefix. Polar uses it, and it ships libraries for several languages.",[64,65,66],"test-card-numbers","merchant-of-record-comparison","secret-key-generator",{"body":68,"to":69},"Nuxter verifies its Stripe, Polar, Lemon Squeezy and Creem webhooks with the same checks, from Stripe's list of v1 signatures and its timestamp window to both Polar keys. This page runs a browser port of that code.","\u002Ffeatures",{"type":71,"value":72,"toc":257},"minimark",[73,78,82,85,89,92,100,203,206,210,221,224,228,231,234,238,253],[74,75,77],"h2",{"id":76},"what-a-signature-proves","What a signature proves",[79,80,81],"p",{},"Every provider here signs a webhook the same basic way: an HMAC-SHA256 over the request, keyed with a secret only you and the provider hold. Your server computes the same HMAC and compares the two in constant time. A match proves the request came from someone with the secret and that the signed bytes did not change on the way. It proves nothing about when it was sent unless a timestamp is part of what was signed.",[79,83,84],{},"The providers differ in the details, and each detail breaks a hand-written check: the header name, what gets signed besides the body, how the secret becomes a key, and whether the digest is hex or base64. The table further down lists all of them.",[74,86,88],{"id":87},"verify-the-raw-body-not-the-parsed-one","Verify the raw body, not the parsed one",[79,90,91],{},"The signature covers bytes. Parse the JSON and write it out again and the bytes change, even when the data does not: whitespace, key order and escaping all move. Read the body as a string, verify that string, and only then parse it.",[79,93,94,95,99],{},"In Nuxt, that is ",[96,97,98],"code",{},"readRawBody"," from h3:",[101,102,107],"pre",{"className":103,"code":104,"language":105,"meta":106,"style":106},"language-ts shiki shiki-themes github-light github-light github-dark","export default defineEventHandler(async (event) => {\n  const body = await readRawBody(event, 'utf8')\n  const signature = getHeader(event, 'stripe-signature')\n})\n","ts","",[96,108,109,148,177,197],{"__ignoreMap":106},[110,111,114,118,121,125,129,132,135,139,142,145],"span",{"class":112,"line":113},"line",1,[110,115,117],{"class":116},"s8jYJ","export",[110,119,120],{"class":116}," default",[110,122,124],{"class":123},"snPdu"," defineEventHandler",[110,126,128],{"class":127},"sxrX7","(",[110,130,131],{"class":116},"async",[110,133,134],{"class":127}," (",[110,136,138],{"class":137},"sP4rz","event",[110,140,141],{"class":127},") ",[110,143,144],{"class":116},"=>",[110,146,147],{"class":127}," {\n",[110,149,151,154,158,161,164,167,170,174],{"class":112,"line":150},2,[110,152,153],{"class":116},"  const",[110,155,157],{"class":156},"sBjJW"," body",[110,159,160],{"class":116}," =",[110,162,163],{"class":116}," await",[110,165,166],{"class":123}," readRawBody",[110,168,169],{"class":127},"(event, ",[110,171,173],{"class":172},"sIIMD","'utf8'",[110,175,176],{"class":127},")\n",[110,178,180,182,185,187,190,192,195],{"class":112,"line":179},3,[110,181,153],{"class":116},[110,183,184],{"class":156}," signature",[110,186,160],{"class":116},[110,188,189],{"class":123}," getHeader",[110,191,169],{"class":127},[110,193,194],{"class":172},"'stripe-signature'",[110,196,176],{"class":127},[110,198,200],{"class":112,"line":199},4,[110,201,202],{"class":127},"})\n",[79,204,205],{},"When a check fails, the tool tries the usual changes on the body you pasted: trimmed, LF and CRLF line endings swapped, a byte order mark removed, and the JSON written compact or indented. If one of them matches, the body changed after the provider signed it, and the tool says which change it was.",[74,207,209],{"id":208},"polars-two-keys","Polar's two keys",[79,211,212,213,216,217,220],{},"Polar sends Standard Webhooks headers, but the key depends on when the secret was made. Secrets from 8 September 2026, 00:00 UTC onward are Standard Webhooks secrets: the part after ",[96,214,215],{},"whsec_"," is base64, and its decoded bytes are the key. Older secrets use Polar HMAC, where the key is the whole ",[96,218,219],{},"whsec_…"," string as UTF-8.",[79,222,223],{},"Polar's SDKs from 1.0.0-alpha.19 try both keys. A plain Standard Webhooks library tries only the new one, so for an older secret, base64-encode the whole string and pass that instead. The verifier tries both and names the one that matched.",[74,225,227],{"id":226},"timestamps-replays-and-duplicates","Timestamps, replays and duplicates",[79,229,230],{},"Stripe, Polar, Standard Webhooks and Paddle sign a timestamp, and a server should refuse a delivery whose timestamp sits too far from its own clock, even when the signature matches. That stops an attacker who captured one request from replaying it later. Keep the server's clock synced. Stripe signs each retry afresh, with a new timestamp.",[79,232,233],{},"Lemon Squeezy, Creem and GitHub sign the body alone, so a captured request verifies forever. Retries also mean the same event can arrive twice, as Stripe and Creem both warn. Record the event ID once you have handled it, and skip IDs you have seen.",[74,235,237],{"id":236},"rotating-a-secret","Rotating a secret",[79,239,240,241,244,245,248,249,252],{},"Stripe lets you keep an old secret alive for up to 24 hours after rolling it, and sends one ",[96,242,243],{},"v1"," signature per active secret in the meantime. Standard Webhooks puts several space-separated signatures in ",[96,246,247],{},"webhook-signature",", and Paddle says a header can carry more than one ",[96,250,251],{},"h1"," while a secret is rotated out. A check passes when any one of them matches, which is why the tool lists every signature it received and marks the one that did.",[254,255,256],"style",{},"html pre.shiki code .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .snPdu, html code.shiki .snPdu{--shiki-light:#6F42C1;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sxrX7, html code.shiki .sxrX7{--shiki-light:#24292E;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sP4rz, html code.shiki .sP4rz{--shiki-light:#E36209;--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sBjJW, html code.shiki .sBjJW{--shiki-light:#005CC5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIIMD, html code.shiki .sIIMD{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":106,"searchDepth":150,"depth":150,"links":258},[259,260,261,262,263],{"id":76,"depth":150,"text":77},{"id":87,"depth":150,"text":88},{"id":208,"depth":150,"text":209},{"id":226,"depth":150,"text":227},{"id":236,"depth":150,"text":237},"webhook-signature-verifier",null,[267,273,279],{"path":268,"tool":64,"name":269,"title":270,"description":271,"summary":272,"checkedAt":9},"\u002Ftools\u002Ftest-card-numbers","Test card numbers","Test Card Numbers for Stripe, Paddle, Polar and Creem","Test card numbers for Stripe, Polar, Lemon Squeezy, Paddle and Creem, for success, declines, 3D Secure and failed renewals, and where the same card differs.","Cards for success, declines, 3D Secure and failed renewals at Stripe, Polar, Lemon Squeezy, Paddle and Creem.",{"path":274,"tool":65,"name":275,"title":276,"description":277,"summary":278,"checkedAt":9},"\u002Ftools\u002Fmerchant-of-record-comparison","Merchant of record comparison","Merchant of Record Comparison for SaaS Payment Fees","Merchant of record comparison for SaaS. Stripe, Stripe Managed Payments, Paddle, Polar, Lemon Squeezy and Creem fees for your price, volume and tax.","Stripe, Managed Payments, Paddle, Polar, Lemon Squeezy and Creem side by side for your price, volume and tax.",{"path":280,"tool":66,"name":281,"title":282,"description":283,"summary":284,"checkedAt":9},"\u002Ftools\u002Fsecret-key-generator","Secret key generator","Secret Key Generator for .env, JWT and Webhooks","Generate random secret keys in your browser for NUXT_SESSION_PASSWORD, Better Auth, JWT and webhook signing, as hex, base64, base64url or letters and digits.","Secrets for NUXT_SESSION_PASSWORD, Better Auth, JWT and webhooks, as hex, base64 or base64url, with the .env and Wrangler lines.",[]]