You're on Paddle Billing, you copied the secret key, and the
signature check still fails (or your SDK's unmarshal/verify helper throws).
The scheme itself is small — the failures are almost always one of five specific
mistakes. Here's the exact algorithm, then the causes in the order to check them.
Every webhook from Paddle Billing carries one header:
Paddle-Signature: ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151
Two parts, joined by a semicolon: ts is a Unix timestamp, h1
is a hex HMAC-SHA256. The signed payload is the timestamp and the
raw request body joined with a colon:
signed_payload = ts + ":" + raw_body h1 = hex( HMAC_SHA256( key = secret_key, message = signed_payload ) )
The key is your notification destination's secret key — the
pdl_ntfset_… string from Paddle → Developer tools → Notifications →
(overflow menu) → Edit destination. It's used as-is, as a UTF-8 string.
During key rotation the header can carry more than one h1; a delivery is
valid if any of them matches.
request.raw_post, req.rawBody, reading
the stream before a body-parser runs — never against JSON.stringify(parsed).ts is more than 5 seconds old (replay
protection). Two ways this bites: your server clock has drifted (fix with NTP), or
you're testing by replaying a saved payload — the HMAC matches but the SDK rejects it as
expired. For testing, use Paddle's webhook simulator to get a fresh delivery instead of
re-sending an old one.pdl_ntfset_… secret is the HMAC
key exactly as shown. Base64-decoding it, hex-decoding it, or stripping the
prefix all produce a different key and a signature that never matches.ts and
h1 is a semicolon, and the signed payload joins with a
colon — Stripe veterans expect commas and dots (Stripe signs
t.body, comma-separated header) and copy the wrong splitter. Also split
each part on the first = only, and keep all
h1 values, not just the first.The fastest way to find out which cause you're hitting is to look at a real delivery with the signature already checked:
pdl_ntfset_… key.You can also check a delivery you already have offline: paste the secret, raw body,
and the whole Paddle-Signature header into the
in-browser signature debugger — it computes the
expected h1 locally (nothing leaves your browser) and tells you exactly
what was signed.
event_id — dedupe on it, because with a 5s
deadline you will eventually process a retry of something you already
handled.Stripe's scheme is the close cousin that causes cause-5 confusion:
Stripe signature verification
failed (signs t.body with dots and commas where Paddle uses colons and
semicolons). Verifying other providers? The
signature debugger covers 15 schemes
client-side.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new
← All guides · Signature debugger · Payload examples · Docs · Hookden vs webhook.site