48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

Paddle webhook signature verification failed: Paddle-Signature (ts and h1) explained

Paddle Billing sends a Paddle-Signature header like ts=1710929255;h1=6c05.... To verify it, build the signed payload as the ts value, a colon, then the raw request body. Compute an HMAC-SHA256 of that with the secret key for the notification destination, and compare the hex result to h1.

Common causes

  1. Parsed or reformatted body. Paddle's docs warn against transforming the body before verifying. Use express.raw or await req.text().
  2. Forgot the timestamp. Hashing only the body won't match. The signed string is ts + ":" + rawBody.
  3. Wrong secret. Each notification destination has its own secret key (it looks like pdl_ntfset_...). It isn't your API key or client-side token. Sandbox and live are separate accounts with separate destinations, so they have separate secrets.
  4. Timestamp too old. Paddle's SDK helpers reject events whose ts is too far from the current time, with a default tolerance of five seconds. Check your server clock (NTP), and don't verify queued events long after they arrived.
  5. Only checking the first h1. Paddle says more than one h1 may appear during secret rotation. Accept the event if any of them matches.

Node (Express), manual verification

app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = (req.get('Paddle-Signature') || '').split(';').map(p => p.split('='));
  const ts = parts.find(([k]) => k === 'ts')?.[1];
  const h1s = parts.filter(([k]) => k === 'h1').map(([, v]) => v);
  if (!ts || !h1s.length) return res.sendStatus(400);
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 5) return res.sendStatus(400);   // replay window, as in Paddle's SDKs
  const expected = crypto.createHmac('sha256', process.env.PADDLE_WEBHOOK_SECRET)
    .update(ts + ':' + req.body.toString('utf8')).digest('hex');
  const ok = h1s.some(h => h.length === expected.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(expected)));
  if (!ok) return res.sendStatus(401);
  res.sendStatus(200);                                   // then process asynchronously
});

Or let the SDK do it. In Node, paddle.webhooks.unmarshal(rawBody, secretKey, signature) verifies the signature and returns the typed event.

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. It verifies Paddle Billing's Paddle-Signature (ts and h1) out of the box. Set SCHEME_<ID>=paddle. It works the same way for Stripe, Lemon Squeezy, Shopify, Square, Twilio (status callbacks) and GitHub. Paddle Classic isn't supported.

Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.

Delivery

Paddle expects an HTTPS endpoint that returns 200 within five seconds. Anything else is retried with exponential backoff, so acknowledge first and do the work afterwards. Make processing idempotent on the event ID.

Paddle Classic is different

Paddle Classic webhooks don't use Paddle-Signature. They're form-encoded POSTs with a p_signature field, which is an RSA signature (SHA-1) over the other fields, sorted and PHP-serialized. You verify it with your account's public key, not an HMAC secret. The code above only works for Paddle Billing.

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.