Guides · Free check · $9 fix pack

Guides

Paddle webhook signature verification failed? Check these 6 things

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.

How Paddle signs a webhook

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).

1. The body was transformed before you hashed it

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().

2. Wrong secret key (each destination has its own)

"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.

3. The timestamp is outside the tolerance

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.

4. You only check the first h1

"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.

5. You return 200 even when verification fails

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).

6. Paddle never gets a 200 in time

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.

After you fix it

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).

Check your Paddle endpoint in 10 seconds (free).

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 check

FAQ

How is the Paddle-Signature header built?

It 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.

Why does Paddle signature verification fail when I replay an old event?

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.

How fast does my Paddle webhook need to respond?

Paddle expects an HTTP 200 within five seconds. Otherwise it retries 3 times in 15 minutes (sandbox) or 60 times over 3 days (live).

Sources

More guides

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.