Verifying Paddle Billing webhooks in PHP — a complete guide

Verifying Paddle Billing webhooks in PHP — a complete guide

posted 4 min read

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.

The webhook signature format

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.

1 Comment

0 votes

More Posts

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1

Cavity on X-Ray: A Complete Guide to Detection and Diagnosis

Huifer - Feb 12

Why Email-Only Contact Forms Are Failing in 2026 (And What Developers Should Do Instead)

JayCode - Mar 2

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Dental Cone Beam Computed Tomography: Your Complete Guide to 3D Dental Imaging

Huifer - Feb 5
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!