Paddle webhook signature verification failing? Check these 5 causes, in order

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.

What Paddle actually sends

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.

The failure causes, in the order to check them

  1. Wrong secret key for this destination. Paddle generates a separate secret key per notification destination — and your sandbox and live accounts have different destinations, so they never share a key. If you created a second destination for testing, or you're pointing sandbox traffic at code configured with the live key, every signature fails. Copy the key from the exact destination that's delivering (Developer tools → Notifications → Edit destination → secret key field).
  2. The body isn't byte-exact. The HMAC covers the raw bytes. Any framework middleware that parses JSON and re-serializes it (whitespace, key order, unicode escapes all shift) breaks verification even with the right key. Verify against the raw request body — request.raw_post, req.rawBody, reading the stream before a body-parser runs — never against JSON.stringify(parsed).
  3. The 5-second timestamp tolerance. Paddle's official SDKs reject any delivery whose 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.
  4. Decoding the key. The 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.
  5. Header parsing bugs. The separator between 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.

See a real delivery verified in one minute

The fastest way to find out which cause you're hitting is to look at a real delivery with the signature already checked:

  1. Create a free capture URL (one click, no signup).
  2. In the bin's settings, choose Paddle Billing as the signature scheme and paste your destination's pdl_ntfset_… key.
  3. In Paddle → Developer tools → Notifications, add the bin URL as a notification destination (or temporarily edit your existing one), and fire a test event from the simulator.
  4. The capture shows the full raw body and headers with a ✓/✗ signature badge. Badge ✓ but your code fails → your code's bug is cause 2, 3, or 5 (Paddle's side is fine). Badge ✗ → wrong key: cause 1 or 4.

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.

Delivery & retry facts worth knowing

Related

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