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.
express.raw or await req.text().ts + ":" + rawBody.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.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.h1 may appear during secret rotation. Accept the event if any of them matches.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.
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 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.