Skip to content

Payments

How Paddle Billing signs webhooks

Paddle Billing signs notifications with HMAC-SHA256 over "{ts}:{body}" in the Paddle-Signature header, expects a 200 within five seconds and retries for about two days.

Checked against Paddle Billing's documentation on October 31, 2026. Paddle Billing webhook docs

How the signature works

HeaderPaddle-Signature: ts={timestamp};h1={signature}
Signed content{timestamp}:{raw body}
AlgorithmHMAC-SHA256
Encodinghex
Keypdl_ntfset_… used as text, prefix included
Where to find itThe endpoint secret key (pdl_ntfset_…) of this notification destination (Paddle → Developer tools → Notifications → the destination → Secret key).
Replay protectionPaddle's SDKs reject a ts more than 5 seconds old by default; allow more if your clocks drift.
Secret rotationSeveral h1 values may appear during a secret rotation.
  • Paddle Classic uses a different, public-key (p_signature) scheme.

verified Source: developer.paddle.com/webhooks/signature-verification

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 5;

/**
 * Verify a Paddle Billing webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['paddle-signature'] || '';
  const pairs = header.split(';').map((part) => part.trim().split('='));
  const ts = pairs.find(([k]) => k === 'ts')?.[1] || '';
  const signatures = pairs.filter(([k]) => k === 'h1').map(([, v]) => v);
  const signed = Buffer.concat([Buffer.from(ts + ':'), rawBody]);
  const key = secret;
  const expected = crypto.createHmac('sha256', key).update(signed).digest('hex');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig.toLowerCase());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  const signedAt = Number(ts);
  return Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= TOLERANCE_SECONDS;
}

// Express: keep the raw body for this route
// app.post('/webhooks/paddle', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.PADDLE_WEBHOOK_SECRET)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a Paddle Billing webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('paddle-signature', '')
    pairs = [part.strip().split('=', 1) for part in header.split(';')]
    ts = next((p[1] for p in pairs if len(p) == 2 and p[0] == 'ts'), '')
    signatures = [p[1] for p in pairs if len(p) == 2 and p[0] == 'h1']
    signed = (ts + ':').encode() + raw_body
    key = secret.encode()
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = digest.hex()
    if not any(hmac.compare_digest(sig.lower(), expected) for sig in signatures):
        return False
    try:
        signed_at = int(ts)
    except ValueError:
        return False
    return abs(time.time() - signed_at) <= TOLERANCE_SECONDS


# Flask: verify_webhook(request.get_data(), request.headers, SECRET)
<?php

const TOLERANCE_SECONDS = 5;

/**
 * Verify a Paddle Billing webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['paddle-signature'] ?? '');
    $ts = '';
    $signatures = [];
    foreach (explode(';', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 'ts') {
            $ts = $v;
        } elseif ($k === 'h1') {
            $signatures[] = $v;
        }
    }
    $signed = $ts . ':' . $rawBody;
    $key = $secret;
    $expected = hash_hmac('sha256', $signed, $key);
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, strtolower($sig));
    }
    if (! $valid) {
        return false;
    }
    $signedAt = (int) $ts;

    return $signedAt > 0 && abs(time() - $signedAt) <= TOLERANCE_SECONDS;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 5

// VerifyWebhook checks a Paddle Billing webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("Paddle-Signature")
	ts := ""
	var signatures []string
	for _, part := range strings.Split(header, ";") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "ts":
			ts = v
		case "h1":
			signatures = append(signatures, v)
		}
	}
	signed := []byte{}
	signed = append(signed, ts...)
	signed = append(signed, ":"...)
	signed = append(signed, rawBody...)
	key := []byte(secret)
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := hex.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) {
			valid = true
		}
	}
	if !valid {
		return false
	}
	n, err := strconv.ParseFloat(ts, 64)
	if err != nil {
		return false
	}
	signedAt := n
	return math.Abs(float64(time.Now().Unix())-signedAt) <= toleranceSeconds
}

Headers Paddle Billing sends

HeaderMeaning
Paddle-Signaturets= timestamp and h1= signature

Timeouts and retries

Response timeoutReturn 200 within five seconds; slower responses are failures.
Counts as success200 only; any other status is a failure and is retried.
RetriesLive: up to 60 attempts over about two days (about 10 in the first hour, 45 in the first day), waiting 60 × n^1.1 seconds. Sandbox: 3 attempts within about two minutes.
OrderingNot guaranteed: compare occurred_at before applying a change.
DuplicatesPossible; use event_id / notification_id.
DisablingNot documented.

verified Source: developer.paddle.com/webhooks/respond-to-webhooks

Event types

The event is in event_type (in the JSON body). Examples:

transaction.completedtransaction.paidsubscription.createdsubscription.updatedsubscription.canceled

verified Source: developer.paddle.com/webhooks/overview

Source IP addresses

Paddle Billing sends webhooks from these addresses (12 ranges, as of 2026-10-31):

34.232.58.13/32
34.195.105.136/32
34.237.3.244/32
35.155.119.135/32
52.11.166.252/32
34.212.5.7/32
34.194.127.46/32
54.234.237.108/32
3.208.120.145/32
44.226.236.210/32
44.241.183.62/32
100.20.172.113/32

Live and sandbox addresses together (the first six are live).

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

verified Source: developer.paddle.com/webhooks/respond-to-webhooks

Common mistakes

  • Using the default 5-second tolerance on a server whose clock drifts.
  • Mixing up the separators: Paddle signs ts + ":" + body, and the header uses ; between parts.
  • Using the API key or client-side token instead of the destination's secret key.
  • Applying events in arrival order: compare occurred_at.

Stuck? Point the webhook at a free webhook inspector bin: it detects Paddle, checks the signature with your secret in the browser and shows the expected value next to the received one.

FAQ

How do I verify a Paddle Billing webhook signature?

Compute HMAC-SHA256 over {timestamp}:{raw body} using endpoint secret key (pdl_ntfset_…), encode it as hex and compare it in constant time with the value in Paddle-Signature (format: ts={timestamp};h1={signature}). Then reject requests whose timestamp is more than 5 seconds away from your clock.

How long does Paddle Billing wait for a webhook response?

Return 200 within five seconds; slower responses are failures. Success means: 200 only; any other status is a failure and is retried.

Does Paddle Billing retry failed webhooks?

Live: up to 60 attempts over about two days (about 10 in the first hour, 45 in the first day), waiting 60 × n^1.1 seconds. Sandbox: 3 attempts within about two minutes.

What IP addresses does Paddle Billing send webhooks from?

Paddle Billing publishes its webhook source addresses: https://developer.paddle.com/webhooks/respond-to-webhooks. Signatures are still the real check, because proxies and CDNs can change the address you see.

Why does my Paddle Billing signature check fail?

Using the default 5-second tolerance on a server whose clock drifts. Mixing up the separators: Paddle signs ts + ":" + body, and the header uses ; between parts.

Other webhook providers

Stripe GitHub GitLab Bitbucket Cloud Shopify Slack Twilio Zoom Linear Square PayPal SendGrid Mailgun Discord Svix Standard Webhooks Twitch EventSub Intercom HubSpot Jira Cloud Calendly Typeform