How to Secure and Verify Paddle Webhooks
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 senth1: The HMAC-SHA256 signature in hexadecimal format
The signature generation process
Paddle generates the signature by:
- Concatenating the timestamp (
ts) with the raw request body, joined by a colon (:) - Computing an HMAC-SHA256 hash of this concatenated string using your webhook's secret key
- 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:
- Log in to your Paddle dashboard
- Navigate to Developer Tools > Notifications
- Create a new webhook destination or edit an existing one
- 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
- Extract the
Paddle-Signatureheader from the incoming request - Parse the timestamp and signature (format:
ts=<timestamp>;h1=<hex_signature>) - Check the timestamp to prevent replay attacks
- Compute an HMAC of the timestamp and raw request body using your stored secret
- 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
- Copy the Hookdeck Source URL from the CLI output
- In Paddle, go to Developer Tools > Notifications
- Create or edit a webhook destination
- Set the URL to your Hookdeck Source URL
- Select the events you want to receive
- Save the configuration
Step 5: Configure source verification
- Open the Hookdeck Dashboard
- Navigate to Connections and select your source
- Under Advanced Source Configuration, enable Source Authentication
- Select HMAC as the authentication method
- 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:
- Hookdeck receives the webhook from Paddle
- Hookdeck validates the
Paddle-Signatureagainst the payload - Valid requests are forwarded to your endpoint with
x-hookdeck-verified: true - 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:
- Raw body usage: Ensure you're using the exact bytes received, not a parsed/stringified version
- Secret accuracy: Confirm the secret matches what's configured in Paddle
- Timestamp format: Ensure you're correctly parsing and using the timestamp from the header
- Encoding: Paddle uses hex encoding for signatures
Timestamp validation fails
- Server time sync: Ensure your server's clock is synchronized (use NTP)
- Increase tolerance: If clock drift is an issue, increase your timestamp tolerance (but be aware of replay attack implications)
Webhooks not arriving
- Check your endpoint URL: Ensure Paddle has the correct URL configured
- Verify HTTPS: Paddle requires HTTPS endpoints in production
- Check firewall rules: Ensure Paddle's IP addresses can reach your server
- 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.