Paddle webhook signature verification failed
Short answer: Your app got the event from Paddle, but the signature check said no. That is almost always one of three things. The body changed before you checked it. The secret key is wrong or missing. Or the check ran more than 5 seconds after Paddle signed the event. Read the body as raw text, use the secret key of the right notification destination, and check the signature first, as soon as the request comes in.
Signs you have this problem
- Your logs show
[Paddle] Webhook signature verification failed. - Your own check returns 401 or 400 for every event, even real ones.
- In Paddle, the event shows failed attempts, and Paddle keeps retrying.
- The payment went through, but your app did not unlock anything.
How Paddle signs an event
Each event has a Paddle-Signature header like this:
ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151
ts is the time Paddle signed it. h1 is the signature. Paddle joins the time, a colon and the raw body, like 1671552777:{"event_id":...}. Then it makes an HMAC SHA-256 hash of that with your secret key. Your code does the same and checks that both match. If even one space in the body is different, they will not match.
Cause 1: The body changed before the check
This is the most common one. Many tools turn the body into JSON for you. When it is turned back into text, the spaces or the order can change. The data looks the same, but the signature does not match.
- Next.js (App Router): use
await req.text(). Do not usereq.json()before the check. - Express: put
express.raw({ type: 'application/json' })on the webhook route. Anexpress.json()that runs first will break it. - Never check against
JSON.stringify(body). That is a new text, not the one Paddle signed.
Cause 2: The wrong secret key, or none at all
Paddle makes a separate secret key for each notification destination. It starts with pdl_ntfset_. It is not your API key.
- Copy the right one. In Paddle, go to Events > Notifications, edit the destination that sends these events, and copy its secret key. If you have more than one destination, each has its own key.
- Sandbox and live are separate. They don't share settings or notification destinations. Your live app needs the live destination's key.
- Is it set on the server? A key in your
.envfile on your computer does not reach your host. Add it in your host's settings. On Vercel, a change only applies to new deploys, so deploy again after you add it. - Watch for an empty key. Paddle's own example reads the key as
process.env.WEBHOOK_SECRET_KEY || "". If the variable is missing, the check runs with an empty key and fails with the same error. The code below stops with a clear message instead.
Cause 3: The check ran too late
Paddle's SDKs only accept an event for 5 seconds after ts. In the Node SDK, you can't change this. Anything that slows the check counts:
- A tunnel to your computer, like ngrok. A Paddle team member said on GitHub that some tunnels take over 5 seconds to deliver an event. That causes this error. The fix they gave: test on a deployed server.
- Checking later, for example in a background job, or by sending an old request again from your logs.
- A slow start. On serverless hosts, a cold start counts too.
- A server clock that is wrong by a few seconds.
Check the signature first, then reply. Paddle also wants a 200 within five seconds, so do slow work after you reply.
A route that works (Next.js App Router)
This checks the signature by hand, so you can see each step. It reads the raw body, accepts more than one h1 (Paddle may send two while a key is being changed), and uses the same 5-second window as the SDKs.
// app/api/paddle/webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
export const runtime = 'nodejs'
const MAX_AGE_SECONDS = 5 // the same window Paddle's SDKs use
function isValidPaddleSignature(rawBody: string, header: string, secret: string): boolean {
let ts = ''
const h1s: string[] = []
for (const part of header.split(';')) {
const [key, value] = part.split('=')
if (key === 'ts') ts = value ?? ''
if (key === 'h1' && value) h1s.push(value)
}
if (!ts || h1s.length === 0) return false
const age = Math.floor(Date.now() / 1000) - Number(ts)
if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${ts}:${rawBody}`).digest('hex')
return h1s.some(
(h1) => h1.length === expected.length && timingSafeEqual(Buffer.from(h1), Buffer.from(expected)),
)
}
export async function POST(req: Request) {
const secret = process.env.PADDLE_WEBHOOK_SECRET
if (!secret) {
console.error('PADDLE_WEBHOOK_SECRET is not set on this server')
return new Response('Server not set up', { status: 500 })
}
const rawBody = await req.text() // raw text, not req.json()
const header = req.headers.get('paddle-signature') ?? ''
if (!isValidPaddleSignature(rawBody, header, secret)) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
if (event.event_type === 'transaction.completed') {
// Unlock the purchase here. Keep it quick.
}
return new Response('ok', { status: 200 })
}
Using the Paddle Node SDK instead? Pass the same raw text: await paddle.webhooks.unmarshal(rawBody, secret, header). It throws the same error for all three causes, so check them in order.
Test it again
- Deploy the fix. Make sure the secret key is set for production.
- In Paddle, send a test event with the webhook simulator to your deployed URL.
- Look at your logs. You want a
200and no signature error. - Old events that failed may still come in. On live accounts, Paddle retries for about two days. Because of retries, the same event can come more than once, so skip any
event_idyou already handled.
Check it with a free scan
Paste your webhook address into the free Tillping scan: https://tillping.online/#scan, and pick Paddle. No account is needed. Only scan an address you own or are allowed to test.
The scan sends a few safe test requests, with no real payment. One has a fake Paddle-Signature. A good endpoint says no to it, with a 400 or 401. The scan warns you if your endpoint says OK to a fake signature or to no signature, or if it crashes (5xx). For example, the route above answers 500 when the secret key is missing on the server. The scan also flags a 404, a redirect, a Vercel login wall, a Cloudflare challenge, a slow reply and a bad certificate. It can't see your secret key, so it can't tell you if the key is the right one. Use the simulator for that.
Sources
- Paddle, Verify webhook signatures (header format,
ts:body, HMAC SHA-256, secret per destination, 5-second SDK tolerance, more than oneh1): https://developer.paddle.com/webhooks/about/signature-verification - Paddle, Handle webhook delivery (reply
200within five seconds, retry schedule,event_id, simulator): https://developer.paddle.com/webhooks/about/respond-to-webhooks - Paddle, Go-live checklist (sandbox and live don't share notification destinations): https://developer.paddle.com/build/go-live-checklist
- Paddle Node SDK, signature check source (fixed 5-second window): https://github.com/PaddleHQ/paddle-node-sdk/blob/main/src/notifications/helpers/webhooks-validator.ts
- Paddle Node SDK issue #112 (Next.js, ngrok over 5 seconds): https://github.com/PaddleHQ/paddle-node-sdk/issues/112
- Paddle Node SDK issue #160 (Vercel, fixed by using the right secret): https://github.com/PaddleHQ/paddle-node-sdk/issues/160
- Vercel, Environment variables (changes apply to new deploys only): https://vercel.com/docs/environment-variables
Check your webhook now
Paste your webhook address to see what it answers. Free, no account, no keys.
Check my webhookGet an email when Deep Scans open
One email to confirm, then one when we launch. Leave any time.
