DEV Community

EventDock
EventDock

Posted on • Originally published at eventdock.app

How to Verify Paddle Billing Webhook Signatures (and the Raw-Body Bug That Breaks Them)

Most Paddle webhook signature failures come down to one thing: you verified the signature against the wrong bytes. Your framework parsed the JSON, you re-serialized it, and the HMAC no longer matches what Paddle signed. The event looks tampered with even though nothing is wrong. This post shows the exact verification Paddle Billing expects, the raw-body trap that breaks it, and the difference between Paddle Billing and the older Paddle Classic scheme that sends people down the wrong path.

EventDock's own billing runs on Paddle Billing, so the code below is the same shape we use in production.

First: are you on Paddle Billing or Paddle Classic?

Paddle has two products with two completely different webhook signature schemes, and a lot of the older blog posts and Stack Overflow answers describe the wrong one.

  • Paddle Classic (the original product) sends a p_signature field inside the form-encoded body. It requires PHP serialization and is verified with Paddle's public key.
  • Paddle Billing (the current product) sends a Paddle-Signature HTTP header. It is an HMAC-SHA256 over the raw request body, keyed with a per-notification-destination secret. No public key needed.

If your webhook secret looks like pdl_ntfset_... and you see a Paddle-Signature header, you are on Paddle Billing. Accounts created after August 2023 are on Paddle Billing by default, and Classic is legacy. Everything below is for Paddle Billing.

What the Paddle-Signature header actually contains

Every Paddle Billing webhook arrives with a header like this:

Paddle-Signature: ts=1717000000;h1=eb4d0a2f...<64 hex chars>
Enter fullscreen mode Exit fullscreen mode

Two fields, separated by a semicolon:

  • ts is a Unix timestamp (seconds) for when Paddle sent the request.
  • h1 is the HMAC-SHA256 digest, in lowercase hex.

The detail that the field names hide: Paddle does not sign the body on its own. It signs the string {ts}:{raw_body}. The timestamp is part of the signed material, which is what lets you reject replayed requests. Miss the ts: prefix and every signature you compute will be wrong even though your HMAC code is correct.

The raw-body trap

HMAC signs bytes, not objects. Paddle computed its digest over the exact bytes it put on the wire. If you hand your verification function anything other than those exact bytes, the digest will not match.

Here is how the bytes get changed without you noticing:

  1. Your web framework reads the request body and parses it into a JSON object for you.
  2. You call your verify function and pass it JSON.stringify(req.body) to turn the object back into a string.
  3. That re-serialized string is not byte-identical to what Paddle sent. Key order can change, whitespace disappears, unicode escapes differ, floating-point numbers get reformatted.
  4. Your HMAC is computed over different bytes, so h1 never matches, and you reject every real webhook as invalid.

The fix is always the same: verify against the raw, unparsed request body, then parse it only after the signature passes. Every framework has its own way to get the raw body, and getting it wrong is the most common cause of Paddle verification failures.

A correct verification in Node.js

It reads the two header fields, rebuilds the signed string as ts:body, computes the HMAC, compares in constant time, and rejects requests whose timestamp is too old to be fresh.

import crypto from 'crypto';

function verifyPaddleSignature(
  rawBody: string,       // the exact bytes Paddle sent, NOT a re-stringified object
  signatureHeader: string,
  secret: string         // your notification destination secret, pdl_ntfset_...
): boolean {
  // Header looks like: ts=1717000000;h1=abc123...
  let ts = '';
  let h1 = '';
  for (const part of signatureHeader.split(';')) {
    const [key, value] = part.split('=');
    if (key === 'ts') ts = value;
    if (key === 'h1') h1 = value;
  }
  if (!ts || !h1) return false;

  // Reject replays: the timestamp is signed, so an attacker cannot forge a fresh one.
  // Paddle's own examples use a tight 5-second window. Widen it a little if your
  // servers see clock skew, but keep it short.
  const age = Math.floor(Date.now() / 1000) - Number(ts);
  if (age > 5) return false;

  // Paddle signs the string `${ts}:${rawBody}`, not the body alone.
  const signedPayload = `${ts}:${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time compare to avoid leaking the digest through timing.
  const a = Buffer.from(h1);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

Notice there is no JSON.parse anywhere in that function. Parsing happens after it returns true, in your handler, never before.

Getting the raw body in Express, Next.js, and Cloudflare Workers

The verify function is the simple part. Feeding it the untouched body is where frameworks fight you.

Express

The default express.json() middleware consumes the stream and leaves you only the parsed object. Use the raw parser on the webhook route so you keep the bytes:

app.post('/webhooks/paddle',
  express.raw({ type: 'application/json' }),   // req.body is now a Buffer
  (req, res) => {
    const sig = req.get('Paddle-Signature') ?? '';
    if (!verifyPaddleSignature(req.body.toString('utf8'), sig, process.env.PADDLE_WEBHOOK_SECRET)) {
      return res.status(400).send('bad signature');
    }
    const event = JSON.parse(req.body.toString('utf8'));
    res.status(200).send('ok');   // ack fast, process after
    handlePaddleEvent(event).catch(console.error);
  }
);
Enter fullscreen mode Exit fullscreen mode

Next.js (App Router)

Route handlers give you the raw body directly through await req.text(). Do not call req.json() first, because you cannot read the body twice.

export async function POST(req: Request) {
  const raw = await req.text();               // exact bytes
  const sig = req.headers.get('paddle-signature') ?? '';
  if (!verifyPaddleSignature(raw, sig, process.env.PADDLE_WEBHOOK_SECRET!)) {
    return new Response('bad signature', { status: 400 });
  }
  const event = JSON.parse(raw);
  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Cloudflare Workers

Workers give you the raw text with await request.text() and ship the Web Crypto API, so you do not even need the Node crypto module. This is close to what EventDock runs.

const raw = await request.text();
const sig = request.headers.get('paddle-signature') ?? '';
// verify with crypto.subtle.importKey + crypto.subtle.sign('HMAC', ...)
// compare the hex digest to h1
Enter fullscreen mode Exit fullscreen mode

A checklist when it still fails

If you did all that and Paddle webhooks still read as invalid, walk this list in order:

  • Are you signing ts:body and not just body? This is the second most common miss after the raw body.
  • Is the secret the right one? Paddle Billing gives each notification destination its own secret. If you have a sandbox destination and a production destination, they have different secrets. A sandbox secret against production traffic fails silently.
  • Sandbox versus production. Sandbox events come from your sandbox destination and must be checked with the sandbox secret. Do not share one secret across both.
  • Is a proxy rewriting the body? Some API gateways and body-logging middleware re-encode JSON before it reaches your handler. Check the bytes at the very edge.
  • Is h1 compared as lowercase hex? Paddle sends lowercase. If you uppercase or Base64 anywhere, the compare fails.

Verification is step one. Delivery is the real problem.

Signature verification tells you a webhook is authentic. It does nothing for the webhooks that never reach you, or the ones you drop because your handler was mid-deploy when Paddle called.

Paddle Billing retries a failed webhook with backoff over about three days, then stops. That window sounds forgiving until a bad deploy, a database timeout, or a cold start eats a burst of subscription.activated and transaction.completed events during the minutes a customer is actually paying you. Lose one and you get a paying customer stuck on a free-tier account, or a churned customer your system still thinks is active.

This is the problem EventDock solves. You point Paddle at EventDock instead of directly at your app. EventDock verifies the signature, stores the event, and acknowledges Paddle right away so the delivery is safely captured before the retry window matters, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.

You can wire it up with the free tier and watch every Paddle event get captured and delivered. Start with EventDock free, or read how the exactly-once processing pattern handles the duplicate deliveries that every retrying provider, Paddle included, will eventually send you.

Top comments (0)