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.
- Runs in your browser
Checked
01Tool
Tool
Paste a raw body, its signature headers and the secret, or load an example. The check runs as you type.
02Guide
How it works
What a signature proves
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.
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.
Verify the raw body, not the parsed one
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.
In Nuxt, that is readRawBody from h3:
export default defineEventHandler(async (event) => {
const body = await readRawBody(event, 'utf8')
const signature = getHeader(event, 'stripe-signature')
})
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.
Polar's two keys
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 whsec_ is base64, and its decoded bytes are the key. Older secrets use Polar HMAC, where the key is the whole whsec_… string as UTF-8.
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.
Timestamps, replays and duplicates
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.
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.
Rotating a secret
Stripe lets you keep an old secret alive for up to 24 hours after rolling it, and sends one v1 signature per active secret in the meantime. Standard Webhooks puts several space-separated signatures in webhook-signature, and Paddle says a header can carry more than one 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.
03Schemes
How each provider signs
Every provider here signs with HMAC-SHA256 and differs in the rest.
| Provider | Headers | Signed content | Key | Digest | Timestamp window |
|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {t}. | The whole secret as UTF-8, whsec_ included | Hex | 300 s |
| Polar | webhook-idwebhook-timestampwebhook-signature | {webhook-id}. | As Standard Webhooks, or the whole whsec_ string as UTF-8 for secrets made before 8 Sep 2026 | Base64 | ±300 s |
| Standard Webhooks | webhook-idwebhook-timestampwebhook-signature | {webhook-id}. | The base64 after whsec_, decoded | Base64 | ±300 s |
| Lemon Squeezy | X-Signature | {body} | The signing secret as UTF-8 | Hex | None |
| Creem | creem-signature | {body} | The webhook secret as UTF-8 | Hex | None |
| Paddle | Paddle-Signature | {ts}: | The endpoint secret key as UTF-8, pdl_ntfset_ included | Hex | 5 s |
| GitHub | X-Hub-Signature-256 | {body} | The webhook secret as UTF-8 | Hex | None |
04Questions
Questions
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
05Sources
Sources
Every fact on this page was read from these pages on .
- Stripe docs, Receive Stripe events in your webhook endpointdocs.stripe.com/
webhooks - Stripe docs, Resolve webhook signature verification errorsdocs.stripe.com/
webhooks/ signature - Stripe docs, API keysdocs.stripe.com/
keys - Polar docs, Handle and monitor webhook deliveriespolar.sh/
docs/ integrate/ webhooks/ delivery - Standard Webhooks specificationgithub.com/
standard-webhooks/ standard-webhooks/ blob/ main/ spec/ standard-webhooks.md - Standard Webhooks JavaScript librarygithub.com/
standard-webhooks/ standard-webhooks/ blob/ main/ libraries/ javascript/ src/ index.ts - Lemon Squeezy docs, Signing requestsdocs.lemonsqueezy.com/
help/ webhooks/ signing-requests - Creem docs, Webhooksdocs.creem.io/
code/ webhooks - Paddle docs, Verify webhook signaturesdeveloper.paddle.com/
webhooks/ signature-verification - GitHub docs, Validating webhook deliveriesdocs.github.com/
en/ webhooks/ using-webhooks/ validating-webhook-deliveries - h3 docs, Request utilsv1.h3.dev/
utils/ request
Found something out of date? Tell us.
Nuxter
Building a SaaS on Nuxt?
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.