Paddle Billing signs every webhook with a Paddle-Signature header. If your check rejects real events, or Paddle shows failed notifications, work down this list.
The header looks like ts=1671552777;h1=eb4d0dc8…. To verify, Paddle concatenates "the timestamp (ts) with the body of the request, joined with a colon (:)", computes an HMAC with SHA256 using the destination's secret key, and compares it to h1 (Paddle docs: Verify webhook signatures).
Paddle's warning is blunt: "Don't transform or process the raw body of the request, including adding whitespace or applying other formatting" (source). In Express, mount express.raw({ type: "application/json" }) on the webhook route. In a Next.js route handler, use await request.text().
"Paddle generates a secret key for each notification destination that you create." Copy it from Paddle > Events > Notifications → Edit destination (source). Sandbox and live are separate accounts, so production needs the live destination's key.
Paddle's SDK helpers "check the timestamp (ts) against the current time and reject events that are too old. The default tolerance between the timestamp and the current time is five seconds" (source). A server clock that's off, or replaying a saved payload in a test, fails even with the right key. Sync the clock (NTP) and test with Paddle's webhook simulator, which sends a freshly signed request.
"Signatures contain at least one h1… During secret rotation, more than one h1 is returned" (source). Accept the event if any h1 matches.
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(request: Request) {
const header = request.headers.get("paddle-signature") ?? "";
const secret = process.env.PADDLE_WEBHOOK_SECRET_KEY!;
const rawBody = await request.text(); // never parse first
const parts = header.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 === 0) return new Response("Invalid request", { status: 400 });
const expected = createHmac("sha256", secret).update(`${ts}:${rawBody}`, "utf8").digest("hex");
const ok = h1s.some((h) => h.length === expected.length && timingSafeEqual(Buffer.from(h), Buffer.from(expected)));
if (!ok) return new Response("Invalid signature", { status: 401 });
// store the event, then return 200 within five seconds; process afterwards
return new Response("OK", { status: 200 });
}
Condensed from Paddle's manual-verification example (source). The official SDKs do the same in one call: paddle.webhooks.unmarshal(rawBody, secretKey, signature) in Node.
Paddle's quickstart sample catches the error, logs it, and still sends a success response, which is fine for a demo (Paddle docs: Webhooks quickstart). In production, make sure nothing runs on an unverified event, and reject it with a 4xx the way Paddle's manual examples do (400 for a malformed header, 401 for a mismatch).
Your server must use HTTPS, accept POST with JSON, and return 200 within five seconds (Paddle docs: Handle webhook delivery). Otherwise Paddle retries: "For sandbox accounts, we retry 3 times within 15 minutes. For live accounts, we retry 60 times within 3 days" (source). Paddle also warns that a WAF can block its requests, and recommends bypassing bot checks on the webhook path, restricted to Paddle's IPs and the Paddle user agent (source). Redirects and login walls (Vercel protection, Supabase JWT, auth middleware) cause the same failure.
When all attempts are used up, a notification's status becomes failed; you can replay it with the replay-notification operation in the Paddle API (source).
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Paddle can reach it and whether it rejects unsigned and forged events. If it finds a problem, the $9 fix pack gives you the exact copy-paste Paddle fix for your stack (refundable for 14 days).
Run the free Paddle checkIt contains ts (a Unix timestamp) and one or more h1 values. h1 is an HMAC-SHA256 hex digest of ts, a colon, and the raw request body, keyed with the notification destination's secret key.
Paddle's SDKs reject events whose timestamp is more than five seconds from the current time by default. Use the webhook simulator for a freshly signed request, and keep your server clock in sync.
Paddle expects an HTTP 200 within five seconds. Otherwise it retries 3 times in 15 minutes (sandbox) or 60 times over 3 days (live).
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-10-19. WebhookProof is an independent tool, not affiliated with Paddle. Quotes are from the linked documentation.