Webhook Debugging Guide

Paddle webhook signature verification failed (Paddle-Signature)

Short answer

Paddle Billing signs every webhook with a Paddle-Signature header:

Paddle-Signature: ts=1759536000;h1=2a510e570dadcbbbca559f03afc787ee879fa0238e79d8bc8d038cbde94a7160

h1 is a hex HMAC-SHA256 of ts, a colon, and the raw request body, keyed with the notification destination's secret key (it starts with pdl_ntfset_). If the Node SDK throws [Paddle] Webhook signature verification failed, one of three things is true: the secret is wrong, the body isn't byte-for-byte what Paddle sent, or the event is more than 5 seconds old. Both official SDKs reject anything older than 5 seconds by default, so a replayed or delayed event fails even when the secret and body are right.

Node (Express) with the Paddle SDK

import express from 'express';
import { Paddle, Environment } from '@paddle/paddle-node-sdk';

const paddle = new Paddle(process.env.PADDLE_API_KEY, { environment: Environment.sandbox });
const SECRET = process.env.PADDLE_WEBHOOK_SECRET; // pdl_ntfset_...
const app = express();

// express.raw, not express.json: the signature covers the exact bytes Paddle sent.
app.post('/paddle/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.get('paddle-signature') ?? '';
  try {
    const event = await paddle.webhooks.unmarshal(req.body.toString('utf8'), SECRET, signature);
    console.log(event.eventType, event.data.id);
    res.sendStatus(200);
  } catch (err) {
    console.error(err.message);
    res.sendStatus(401);
  }
});

app.listen(3000);

unmarshal is async and throws two different messages. [Paddle] Invalid webhook signature means the header was missing or had no ts or h1, so nothing was compared. [Paddle] Webhook signature verification failed means the header parsed but the check failed. In version 3.10 of @paddle/paddle-node-sdk the 5-second limit is a fixed constant, with no option to change it; paddle.webhooks.isSignatureValid runs the same check and returns false instead of throwing.

Python (Flask) with the Paddle SDK

import os

from flask import Flask, abort, request
from paddle_billing.Notifications import Secret, Verifier

app = Flask(__name__)
SECRET = os.environ["PADDLE_WEBHOOK_SECRET"]  # pdl_ntfset_...


@app.post("/paddle/webhook")
def paddle_webhook():
    if not Verifier().verify(request, Secret(SECRET)):
        abort(401)
    event = request.get_json()
    print(event["event_type"], event["data"]["id"])
    return "", 200

Verifier reads the Paddle-Signature header and the raw body from the request object itself (request.data in Flask, request.body in Django). It returns False for a missing header, a wrong signature, or an event older than maximum_variance seconds, which defaults to 5. Verifier(maximum_variance=300) allows five minutes. It logs which check failed, to the console by default, so look at your server's output if all you see is the 401.

Without the SDK, with your own tolerance

import crypto from 'node:crypto';

function isValidPaddleSignature(secret, rawBody, header, toleranceSeconds = 5) {
  if (typeof header !== 'string') return false;
  let ts = null;
  const h1 = [];
  for (const part of header.split(';')) {
    const [key, value] = part.split('=', 2);
    if (key === 'ts') ts = value;
    else if (key === 'h1' && value) h1.push(value);
  }
  if (!ts || !/^\d+$/.test(ts) || h1.length === 0) return false;
  if (Math.floor(Date.now() / 1000) - Number(ts) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(ts + ':'), rawBody]))
    .digest('hex');
  const a = Buffer.from(expected);
  return h1.some((sig) => {
    const b = Buffer.from(sig);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

rawBody is the Buffer from express.raw. The function accepts any h1 in the header, so it keeps working if more than one is present, and the tolerance is yours to set: keep it short in production, where the timestamp is what stops a captured request being replayed later.

Test your handler from a terminal

Sign a body with a test secret and post it to your local server. Run your handler with PADDLE_WEBHOOK_SECRET=pdl_ntfset_test for this:

SECRET=pdl_ntfset_test
BODY='{"event_id":"evt_01","event_type":"customer.created","data":{"id":"ctm_01"}}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS:$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i http://localhost:3000/paddle/webhook \
  -H 'Content-Type: application/json' \
  -H "Paddle-Signature: ts=$TS;h1=$SIG" \
  --data-binary "$BODY"

The sed strips the SHA2-256(stdin)= label that Homebrew's OpenSSL 3 prints; the LibreSSL openssl that ships with macOS prints the bare hex. Change TS=$(date +%s) to TS=$(( $(date +%s) - 10 )) and the same request fails, which is the 5-second limit at work. The body is a cut-down customer.created event: enough for the SDKs to return an event, not a complete one. Some event types need more fields than this before the Node SDK's unmarshal can build them, and then it throws a TypeError after the signature has already passed.

Why it fails

Where WebhookMon fits

WebhookMon doesn't have a built-in Paddle signature check: its verdicts cover Stripe, Polar, GitHub, Shopify and Slack, plus any sender using the Standard Webhooks webhook- headers. A Paddle notification destination still works with it for everything else. Point the destination at WebhookMon's relay URL and each event shows up with its exact body and Paddle-Signature header, which is the quickest way to see whether the bytes your handler hashed are the bytes that were sent. It forwards events to your local server, and replays them as captured. Because a replay keeps the original ts, and Paddle's SDKs allow only 5 seconds, a replay fails the SDK check unless you loosen it in development. The same goes for an event that reaches your server late for any reason. See the replay guide for why. If you want a Paddle verdict, ask for it; requests decide what gets built next.

WebhookMon's Headers tab for a captured event, listing each request header name and value exactly as received, next to an event list with signature and local response columns
Download WebhookMon free trial Learn more

Related guides