If you've integrated Paddle Billing into a PHP backend, you've hit this question: how do you verify the webhook signature?
The Paddle docs show Node.js and Python examples but skip PHP. The format is also slightly unusual — it's not a single HMAC, it's a parsed header with timestamp + hash. After integrating it in production, here's the working code and the gotchas nobody mentions.
Paddle sends a Paddle-Signature header that looks like this:
ts=1717423891;h1=4e1c5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d
Two parts joined by ;:
ts — Unix timestamp of when Paddle sent the webhook
h1 — HMAC-SHA256 of {ts}:{raw_body}, hex-encoded
The secret is your endpoint secret (starts with pdl_ntfset_ for live or ntfset_ for sandbox). It's shown once when you create the webhook destination in the Paddle dashboard. If you lost it, regenerate.
The minimal verification function
function verify_paddle_signature(string $signature_header, string $raw_body, string $secret): bool {
// Parse header: "ts=...;h1=..."
$parts = [];
foreach (explode(';', $signature_header) as $segment) {
[$key, $value] = explode('=', $segment, 2);
$parts[trim($key)] = trim($value);
}
if (empty($parts['ts']) || empty($parts['h1'])) {
return false;
}
// Compute expected hash
$signed_payload = $parts['ts'] . ':' . $raw_body;
$expected = hash_hmac('sha256', $signed_payload, $secret);
// Constant-time comparison — never use ===
return hash_equals($expected, $parts['h1']);
}
The full webhook handler
add_action('rest_api_init', function() {
register_rest_route('myapp/v1', '/paddle-webhook', [
'methods' => 'POST',
'callback' => 'handle_paddle_webhook',
'permission_callback' => '__return_true',
]);
});
function handle_paddle_webhook(WP_REST_Request $request) {
$signature = $request->get_header('paddle-signature');
$raw_body = $request->get_body();
$secret = PADDLE_WEBHOOK_SECRET; // from wp-config.php / env
if (empty($signature) || empty($raw_body)) {
return new WP_REST_Response(['error' => 'missing'], 400);
}
if (!verify_paddle_signature($signature, $raw_body, $secret)) {
return new WP_REST_Response(['error' => 'invalid signature'], 401);
}
// Optional: reject events older than 5 minutes (replay protection)
$parts = [];
foreach (explode(';', $signature) as $seg) {
[$k, $v] = explode('=', $seg, 2);
$parts[trim($k)] = trim($v);
}
if (abs(time() - (int) $parts['ts']) > 300) {
return new WP_REST_Response(['error' => 'expired'], 401);
}
// Now safe to process
$event = json_decode($raw_body, true);
do_paddle_event_logic($event);
return new WP_REST_Response(['ok' => true], 200);
}
Five gotchas that cost me hours
1. Use the raw request body, not parsed JSON.
WordPress, Laravel, and Symfony all parse JSON for you. Don't use the parsed array — re-serializing changes whitespace and key order, and your hash will never match. Always read the raw input:
// WordPress REST API
$raw_body = $request->get_body();
// Laravel
$raw_body = $request->getContent();
// Vanilla PHP
$raw_body = file_get_contents('php://input');
2. Sandbox and production secrets are different.
Paddle Sandbox is a separate account at sandbox-vendors.paddle.com. Different webhook endpoints, different secrets. Pick the right one based on your environment:
$secret = (PADDLE_ENV === 'production')
? PADDLE_WEBHOOK_SECRET_LIVE
: PADDLE_WEBHOOK_SECRET_SANDBOX;
3. Header name is case-insensitive but PHP is picky.
WordPress normalizes Paddle-Signature to paddle-signature for get_header(). Vanilla PHP exposes it as $_SERVER['HTTP_PADDLE_SIGNATURE']. Don't assume — log it once during dev to confirm.
4. Always use hash_equals.
If you compare hashes with == or ===, you leak timing info. An attacker can theoretically brute-force the signature byte by byte. hash_equals is constant-time:
// Wrong
return $expected === $received;
// Right
return hash_equals($expected, $received);
5. Return 200 fast. Process slow.
Paddle retries any non-2xx response with exponential backoff for up to three days. If your webhook handler does heavy work (sending emails, updating multiple DB tables, calling third-party APIs), it may time out → Paddle retries → you process the same event multiple times.
The fix:
function handle_paddle_webhook(WP_REST_Request $request) {
// ... verify signature ...
// Save raw event, return immediately
save_event_to_queue($raw_body);
return new WP_REST_Response(['ok' => true], 200);
}
// Process queue async (cron, queue worker, etc.)
For idempotency, use the event's event_id field as a unique key. If you see the same event_id twice, skip.
Testing locally
The Paddle dashboard has a "Send test event" button. It signs the test event with your real secret, so verification works. To receive it on localhost:
# Tunnel to your local server
ngrok http 80
# Use the ngrok URL as the webhook destination in Paddle
For automated tests without hitting Paddle, generate a valid signature in your test code:
$ts = (string) time();
$body = json_encode(['event_type' => 'transaction.completed', /* ... */]);
$h1 = hash_hmac('sha256', "{$ts}:{$body}", $secret);
$header = "ts={$ts};h1={$h1}";
Then post to your handler with that header.
Wrapping up
The signature format is straightforward once you know it: parse ts and h1, hash {ts}:{body} with HMAC-SHA256, compare with hash_equals. The hard part is the small details — raw body, sandbox vs prod secrets, async processing, replay protection.
I'm running this exact verification logic in production on Site2PDF, a website-to-PDF tool. If you're integrating Paddle and hit a snag the docs don't cover, drop a comment — happy to help.