Author picture Gareth Wilson

How to Secure and Verify Paddle Webhooks

Published


Paddle is a complete payments infrastructure for SaaS and software businesses, handling subscriptions, one-time purchases, and global tax compliance. When events occur in Paddle (such as successful payments, subscription changes, or refunds) Paddle sends webhook notifications to your application so you can respond in real-time.

However, accepting webhooks without proper verification leaves your application vulnerable to spoofed requests from malicious actors. In this guide, we'll show you how to secure and verify Paddle webhooks using Hookdeck, ensuring that every webhook your application processes is genuinely from Paddle.

Why webhook security matters

Webhooks are HTTP callbacks that notify your application when events occur. Without verification, an attacker could send fake webhook payloads to your endpoint, potentially triggering unauthorized actions like:

  • Granting access to users who haven't paid
  • Processing fake refund notifications
  • Manipulating subscription states
  • Corrupting your billing data

Paddle addresses this by signing every webhook with an HMAC-SHA256 signature. By verifying this signature, you can be confident that webhooks are authentic and haven't been tampered with in transit.

How Paddle webhook signatures work

Paddle Billing (the current version of Paddle) uses HMAC-SHA256 signature verification. Every webhook includes a Paddle-Signature header containing a timestamp and signature hash.

The Paddle-Signature header

The header format looks like this:

ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151

The header contains two components separated by a semicolon:

  • ts: A Unix timestamp indicating when the webhook was sent
  • h1: The HMAC-SHA256 signature in hexadecimal format

The signature generation process

Paddle generates the signature by:

  1. Concatenating the timestamp (ts) with the raw request body, joined by a colon (:)
  2. Computing an HMAC-SHA256 hash of this concatenated string using your webhook's secret key
  3. Encoding the result as a hexadecimal string

To verify a webhook, you repeat this process and compare your computed signature against the h1 value in the header.

Getting your Paddle webhook secret key

Before you can verify webhooks, you need to obtain your secret key from Paddle:

  1. Log in to your Paddle dashboard
  2. Navigate to Developer Tools > Notifications
  3. Create a new webhook destination or edit an existing one
  4. Find the Secret key field—this is your webhook signing secret

Important: Paddle generates a unique secret key for each notification destination. If you have multiple webhook endpoints, you'll need the corresponding secret key for each one.

Verifying webhook signatures

To ensure incoming webhooks are legitimate, your server must verify the HMAC signature before processing the payload.

Verification process overview

  1. Extract the Paddle-Signature header from the incoming request
  2. Parse the timestamp and signature (format: ts=<timestamp>;h1=<hex_signature>)
  3. Check the timestamp to prevent replay attacks
  4. Compute an HMAC of the timestamp and raw request body using your stored secret
  5. Compare your computed signature with the one from Paddle using a timing-safe comparison

Node.js verification example

const express = require("express");
const crypto = require("crypto");

const app = express();
const WEBHOOK_SECRET = process.env.PADDLE_WEBHOOK_SECRET;

function parseSignatureHeader(header) {
  const match = header.match(/^ts=(\d+);h1=(.+)$/);
  if (!match) return null;

  return {
    timestamp: match[1],
    signature: match[2],
  };
}

function verifyPaddleWebhook(rawBody, signatureHeader, toleranceSeconds = 5) {
  // Parse the signature header
  const parsed = parseSignatureHeader(signatureHeader);
  if (!parsed) {
    console.error("Invalid Paddle-Signature header format");
    return false;
  }

  const { timestamp, signature } = parsed;

  // Check for replay attacks
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp, 10);

  if (Math.abs(currentTime - webhookTime) > toleranceSeconds) {
    console.error("Webhook timestamp is outside tolerance window");
    return false;
  }

  // Build the signed payload
  const signedPayload = `${timestamp}:${rawBody}`;

  // Compute HMAC-SHA256
  const computedSignature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(signedPayload)
    .digest("hex");

  // Use timing-safe comparison to prevent timing attacks
  const receivedBuffer = Buffer.from(signature, "utf8");
  const expectedBuffer = Buffer.from(computedSignature, "utf8");

  if (receivedBuffer.length !== expectedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}

// Important: Use raw body parser for webhook endpoints
app.post(
  "/webhooks/paddle",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signatureHeader = req.headers["paddle-signature"];
    const rawBody = req.body.toString();

    if (!verifyPaddleWebhook(rawBody, signatureHeader)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);

    // Process the verified webhook
    switch (event.event_type) {
      case "subscription.created":
        handleSubscriptionCreated(event);
        break;
      case "transaction.completed":
        handleTransactionCompleted(event);
        break;
      default:
        console.log(`Unhandled event type: ${event.event_type}`);
    }

    res.status(200).json({ received: true });
  }
);

app.listen(3000, () => {
  console.log("Webhook server listening on port 3000");
});

Python verification example

import hmac
import hashlib
import time
import re
import os
from flask import Flask, request, jsonify, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get("PADDLE_WEBHOOK_SECRET")

def parse_signature_header(header):
    """Parse the Paddle-Signature header into timestamp and signature."""
    match = re.match(r'^ts=(\d+);h1=(.+)$', header)
    if not match:
        return None
    return match.group(1), match.group(2)

def verify_paddle_webhook(raw_body, signature_header, tolerance_seconds=5):
    """Verify a Paddle webhook signature."""
    parsed = parse_signature_header(signature_header)
    if not parsed:
        return False

    timestamp, signature = parsed

    # Check for replay attacks
    current_time = int(time.time())
    webhook_time = int(timestamp)

    if abs(current_time - webhook_time) > tolerance_seconds:
        return False

    # Build the signed payload
    signed_payload = f"{timestamp}:{raw_body.decode('utf-8')}"

    # Compute HMAC-SHA256
    computed_signature = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    # Use timing-safe comparison
    return hmac.compare_digest(computed_signature, signature)

@app.route("/webhooks/paddle", methods=["POST"])
def handle_webhook():
    signature_header = request.headers.get("Paddle-Signature", "")
    raw_body = request.get_data()

    if not verify_paddle_webhook(raw_body, signature_header):
        abort(401, "Invalid signature")

    event = request.get_json()

    print(f"Received verified {event['event_type']} event")

    # Process the webhook payload
    if event["event_type"] == "subscription.created":
        handle_subscription_created(event)
    elif event["event_type"] == "transaction.completed":
        handle_transaction_completed(event)

    return jsonify({"received": True}), 200

if __name__ == "__main__":
    app.run(port=3000)

Using the official Paddle SDK

Paddle provides official SDKs that include webhook verification helpers. Using these SDKs is often simpler and less error-prone than manual implementation.

Node.js SDK example

const Paddle = require("@paddle/paddle-node-sdk");
const express = require("express");

const paddle = new Paddle(process.env.PADDLE_API_KEY);

const app = express();

app.post(
  "/webhooks/paddle",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["paddle-signature"];
    const rawBody = req.body.toString();
    const secretKey = process.env.PADDLE_WEBHOOK_SECRET;

    try {
      // The SDK handles parsing and verification
      const event = paddle.webhooks.unmarshal(rawBody, secretKey, signature);

      // Process the verified event
      switch (event.eventType) {
        case "subscription.created":
          // Handle new subscription
          break;
        case "transaction.completed":
          // Handle completed payment
          break;
      }

      res.status(200).json({ received: true });
    } catch (error) {
      console.error("Webhook verification failed:", error);
      res.status(401).json({ error: "Invalid signature" });
    }
  }
);

Critical security best practices

Use timing-safe comparisons

Never use standard equality operators (== or ===) to compare signatures. These can leak timing information that attackers exploit. Always use:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • Go: subtle.ConstantTimeCompare()

Implement replay attack prevention

Check the timestamp in the Paddle-Signature header and reject webhooks that are too old. A tolerance of 5-30 seconds is typically appropriate.

Preserve the raw request body

The HMAC signature is computed on the exact bytes Paddle sends. Any parsing, reformatting, or beautification of the payload before verification will produce a different signature.

Secure secret storage

  • Store secrets in environment variables or a secrets manager
  • Never hardcode secrets in source code
  • Never commit secrets to version control
  • Rotate secrets periodically

Process webhooks idempotently

Paddle may send the same webhook multiple times. Use the event_id to deduplicate and ensure idempotent processing.

Simplifying verification with Hookdeck

Manually implementing and maintaining webhook verification across multiple providers can be complex and error-prone. Hookdeck provides a webhook gateway that handles verification automatically, allowing you to focus on processing events.

What is Hookdeck?

Hookdeck provides an event gateway that sits between webhook providers (like Paddle) and your application. It provides:

  • Automatic signature verification
  • Event queuing and retry logic
  • Request logging and debugging tools
  • Local development tunneling

Setting up Paddle webhooks with Hookdeck

Step 1: Install the Hookdeck CLI

macOS:

brew install hookdeck/hookdeck/hookdeck

npm:

npm install -g hookdeck-cli

Step 2: Authenticate

hookdeck login

This opens your browser for authentication. If you don't have a Hookdeck account, you can create one during this step.

Step 3: Create a connection

hookdeck listen 3000 paddle-source --path /webhooks/paddle

This command:

  • Creates a public URL for receiving webhooks
  • Forwards events to http://localhost:3000/webhooks/paddle
  • Displays the Source URL to configure in Paddle

Step 4: Configure Paddle

  1. Copy the Hookdeck Source URL from the CLI output
  2. In Paddle, go to Developer Tools > Notifications
  3. Create or edit a webhook destination
  4. Set the URL to your Hookdeck Source URL
  5. Select the events you want to receive
  6. Save the configuration

Step 5: Configure source verification

  1. Open the Hookdeck Dashboard
  2. Navigate to Connections and select your source
  3. Under Advanced Source Configuration, enable Source Authentication
  4. Select HMAC as the authentication method
  5. Configure the HMAC settings:
    • Algorithm: SHA-256
    • Header: Paddle-Signature
    • Encoding: Hex
    • Signature Prefix: h1=
    • Secret: Your Paddle webhook secret key

How Hookdeck verification works

When verification is enabled:

  1. Hookdeck receives the webhook from Paddle
  2. Hookdeck validates the Paddle-Signature against the payload
  3. Valid requests are forwarded to your endpoint with x-hookdeck-verified: true
  4. Invalid requests are rejected and logged as "Verification Failed"

This means your application can trust any request from Hookdeck without implementing its own verification logic.

Verifying Hookdeck signatures

For additional security, you can also verify that requests to your endpoint come from Hookdeck by checking the x-hookdeck-signature header:

const crypto = require("crypto");

function verifyHookdeckSignature(req, hookdeckSecret) {
  const signature = req.get("x-hookdeck-signature");

  if (!signature) {
    return false;
  }

  const hmac = crypto.createHmac("sha256", hookdeckSecret);
  const expectedSignature = hmac.update(req.rawBody).digest("base64");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

Hookdeck signatures are Base64-encoded, unlike Paddle's hex-encoded signatures.

Troubleshooting common issues

Signature mismatch

If signatures don't match, verify:

  1. Raw body usage: Ensure you're using the exact bytes received, not a parsed/stringified version
  2. Secret accuracy: Confirm the secret matches what's configured in Paddle
  3. Timestamp format: Ensure you're correctly parsing and using the timestamp from the header
  4. Encoding: Paddle uses hex encoding for signatures

Timestamp validation fails

  1. Server time sync: Ensure your server's clock is synchronized (use NTP)
  2. Increase tolerance: If clock drift is an issue, increase your timestamp tolerance (but be aware of replay attack implications)

Webhooks not arriving

  1. Check your endpoint URL: Ensure Paddle has the correct URL configured
  2. Verify HTTPS: Paddle requires HTTPS endpoints in production
  3. Check firewall rules: Ensure Paddle's IP addresses can reach your server
  4. Review Paddle logs: Check the notification logs in your Paddle dashboard for delivery errors

Conclusion

Securing Paddle webhooks requires verifying the HMAC-SHA256 signature on every incoming request. While you can implement this verification manually or use the official Paddle SDK, Hookdeck simplifies the process by providing automatic signature verification, event management, and development tools, allowing you to focus on building your integration rather than infrastructure.

Additional resources


Author picture

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.