PayHook

[Paddle] Webhook signature verification failed: three causes

Paddle's Node SDK throws one error for three causes: a request older than 5 seconds, a body parsed before the check, or a wrong or missing secret key.

If paddle.webhooks.unmarshal() throws Error: [Paddle] Webhook signature verification failed, the Node SDK found one of three problems and does not say which: the request reached your code more than 5 seconds after Paddle signed it, the body changed before the check, or the secret key is wrong or missing. A missing or broken Paddle-Signature header gives a different message, [Paddle] Invalid webhook signature.

This page shows how to tell the three apart, what Paddle's other SDKs do, and the two five-second limits of Paddle webhooks, with a check in plain Node that names the cause.

One message, three causes

The Node SDK checks the time before the signature. It reads ts from the Paddle-Signature header, and if your server's clock says more than 5 seconds have passed since then, it gives up without computing anything. Otherwise it computes the HMAC-SHA256 of ts, a colon and the body with your secret key, and compares it with the h1 value of the header. Either failure makes unmarshal() throw the same error.

CauseHow to recognize itWhat to do
The request is more than 5 seconds old when you check itIt works on a deployed server but fails through ngrok or another tunnel, or it fails only for requests your code checks laterCheck the signature first thing in the handler, on a deployed URL (local development: below)
The body changed before the checkEvery event fails, everywherePass the raw body: await request.text() in a Next.js route handler, express.raw() in Express
The secret key is wrong or missingEvery event fails, and the body is rawUse the key of the destination that sends these events, and check the variable is set where the code runs

The last cause hides well. Paddle's examples read the key as process.env.WEBHOOK_SECRET_KEY || "", so when the variable is not set on your server, the SDK checks with an empty key and throws the same error.

The 5-second window

Paddle's SDKs reject a signature whose ts is more than 5 seconds old by your server's clock. The check is there against replays: anyone who captured a request could send it again, and the timestamp is part of what Paddle signs. In Node the window is fixed; a Paddle maintainer confirmed there is no setting to change it.

Five seconds is tight, and whatever delays the check counts:

  • A tunnel to localhost. Paddle's maintainers have answered such reports more than once: delivery through ngrok or localtunnel can take more than 5 seconds. In one of them, 8 seconds passed before the handler's first log line.
  • Checking later. A handler that saves the request and verifies it in a background job, a request copied from the logs and sent again with curl, a breakpoint before the check.
  • A slow start. On a serverless platform, the time a cold start takes before your handler runs counts too.
  • A server clock that is a few seconds fast. The check compares Paddle's time with yours.

For local development, test against a deployed preview URL, as Paddle's maintainers suggest, or verify with your own check and a longer window in development only: the Node SDK has no option for that, and the function at the end of this page takes the window as a parameter. Keep 5 seconds in production.

What Paddle's other SDKs do

SDKTime windowWhat a failure looks like
Node, @paddle/paddle-node-sdk 3.10.05 seconds, fixedunmarshal() throws [Paddle] Webhook signature verification failed; isSignatureValid() returns false
Python, paddle-python-sdk 1.15.05 seconds: Verifier(maximum_variance=5); verify(..., verify_time_drift=False) turns it offverify() returns False; for a late request the SDK logs "Too much time has elapsed between the request and this process"
PHP, paddle-php-sdk 1.18.05 seconds: new Verifier(maximumVariance: 5); null turns it offverify() returns false
Go, paddle-go-sdk 5.2.0none, unless you pass VerifierWithTimestampTolerance, which checks both directionsVerify() returns false; its middleware answers 403 "signature mismatch", or 400 "timestamp is too old or too far in the future"

Paddle's documentation says its SDK helpers check the timestamp by default; in the Go SDK that is true only with VerifierWithTimestampTolerance.

The body has to be raw

Paddle signs the body exactly as it sends it. Parse it as JSON and serialize it again, and spaces or key order can change: the data is the same, the HMAC is not. Many frameworks parse JSON for you, so read the body as text before anything else does:

// Next.js route handler, Cloudflare Workers, Deno, Bun: a standard Request
const rawBody = await request.text();

// Express: raw on the webhook route. An app-wide express.json() registered
// before this route parses the body first, and the check fails.
app.post("/paddle/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString(); // req.body is a Buffer here
});

The secret key belongs to one destination

Paddle creates a secret key for every notification destination, and it starts with pdl_ntfset_. With several destinations, each signs with its own key. Copy the key of the destination that sends the events you check: in the dashboard, Events > Notifications, then Edit destination. The usual mix-ups:

  • A sandbox account and a live account are separate, each with its own destinations and keys.
  • A simulation goes to the destination you pick when you create it, and is signed with that destination's key. Only destinations whose usage type is Simulation, or Platform and simulation, receive simulated events.
  • The API key you pass to new Paddle() is a different secret and never verifies a webhook.

In one report from a Vercel deployment, a Paddle maintainer pointed to the key first, and a new key fixed it.

Two five-second limits

The second limit is on your answer. Paddle wants a 200 within five seconds; its delivery guide says any other status code, or no answer in time, is retried with exponential backoff:

AccountRetries
Sandbox3 within 15 minutes
Live60 within 3 days: 20 in the first hour, 47 in the first day

After the last attempt the notification is marked failed, and you can replay it through the API for 90 days; a replay gets a new notification_id and keeps the event_id. So answer first and do slow work after: Paddle recommends putting received events in a queue. Delivery is at least once, and the order is not guaranteed: skip events whose event_id you have handled, and compare occurred_at instead of the order of arrival.

If no request reaches your code at all, check that the destination is active and subscribed to the event, that its URL is public and HTTPS, and that no firewall or bot protection stops Paddle: its documentation lists the webhook IP addresses, which differ for sandbox and live, and suggests skipping bot checks on the webhook path.

Verify a Paddle webhook in Node

This check uses only node:crypto, compares in constant time and returns the cause instead of one error. It accepts several h1 values, which Paddle's documentation says will appear while a secret is rotated.

import { createHmac, timingSafeEqual } from "node:crypto";

// Returns "ok", "missing secret key", "missing header", "malformed header",
// "mismatch" or "expired".
// rawBody: the body exactly as received (string or Buffer), never JSON that was
// parsed and serialized again. secretKey: the endpoint secret key of the
// notification destination that sends these events.
export function checkPaddleSignature(rawBody, header, secretKey, toleranceSeconds = 5) {
  if (!secretKey) return "missing secret key";
  if (!header) return "missing header";
  let ts = "";
  const signatures = [];
  for (const part of header.split(";")) {
    const at = part.indexOf("=");
    if (at < 0) continue;
    const key = part.slice(0, at);
    const value = part.slice(at + 1);
    if (key === "ts") ts = value;
    else if (key === "h1" && value) signatures.push(value);
  }
  if (!/^\d{1,10}$/.test(ts) || signatures.length === 0) return "malformed header";

  // Paddle signs "<ts>:<raw body>" with HMAC-SHA256 and sends the hex digest.
  const hmac = createHmac("sha256", secretKey).update(`${ts}:`).update(rawBody);
  const expected = Buffer.from(hmac.digest("hex"));
  let matches = false;
  for (const signature of signatures) {
    const received = Buffer.from(signature);
    if (received.length === expected.length && timingSafeEqual(received, expected)) matches = true;
  }
  if (!matches) return "mismatch";
  // The check Paddle's SDKs make: older than toleranceSeconds by this server's clock.
  return Date.now() / 1000 - Number(ts) > toleranceSeconds ? "expired" : "ok";
}

Use it first in the handler, and log the cause, never the header or the key:

const rawBody = await request.text();
const signature = request.headers.get("paddle-signature");
const result = checkPaddleSignature(rawBody, signature, process.env.PADDLE_WEBHOOK_SECRET_KEY);
if (result !== "ok") {
  console.warn(`Paddle webhook rejected: ${result}`);
  return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody);

A quick test with a body signed now, signed 8 seconds ago, and serialized again:

import { createHmac } from "node:crypto";

const secretKey = "test-secret-key";
const body = '{"event_id":"evt_1","event_type":"subscription.created"}';
const sign = (ts) => `ts=${ts};h1=${createHmac("sha256", secretKey).update(`${ts}:${body}`).digest("hex")}`;
const now = Math.floor(Date.now() / 1000);

checkPaddleSignature(body, sign(now), secretKey); // "ok"
checkPaddleSignature(body, sign(now - 8), secretKey); // "expired"
checkPaddleSignature(body, sign(now - 8), secretKey, 30); // "ok": a 30-second window, development only
checkPaddleSignature(JSON.stringify(JSON.parse(body), null, 2), sign(now), secretKey); // "mismatch"

On the official SDK's test vectors and on our own cases, this function accepted exactly what @paddle/paddle-node-sdk 3.10.0 accepted, and returned expired or mismatch where the SDK threw its one error.

How PayHook reports it

PayHook, the webhook inspector we are building, checks the signature of each Paddle event as soon as it arrives, and its verdict tells the causes apart: a signature that matches but is outside the 5-second window, a signature that does not match your secret key, or a missing header. It never shows the secret. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog