Paddle Webhook: Example Payload, Headers and Signature

Paddle Billing notifications look a lot like Stripe's until you parse the signature header, which uses different separators and joins the timestamp to the body differently. Here is a completed transaction and how to verify it.

No webhook URL yet. Creating one needs no account.

Request details
Requests sent to your webhook URL appear here: method, headers and the body exactly as it arrived.

The button posts the example below to your webhook URL from your browser. It is not sent by Paddle, it carries your browser's User-Agent, and its signature is a placeholder that will not verify. To see a real delivery, follow the steps further down.

Last updated . How this page is checked.

How the webhook URL works. It is a public address that records whatever is sent to it and answers 200. An inbox keeps the 50 most recent requests, accepts bodies up to 256 KB, and is deleted 24 hours after it was created — or sooner if you press Delete URL or our server restarts, because requests are held in memory and never written to disk.

Anyone who has the URL can read what it received. Use sandbox and test-mode accounts, do not route production traffic or customer data through it, and delete it when you are done. Privacy details.

What is a Paddle webhook?

A Paddle webhook is an HTTP POST sent to a notification destination you created when an event occurs in your Paddle account: a transaction is completed, a subscription is created, updated or cancelled, a customer or address changes, an adjustment such as a refund is made. Paddle is the merchant of record for the sale, so these notifications are how your application learns that a purchase went through.

Every notification has the same envelope. event_type names the event, event_id identifies it, notification_id identifies this delivery of it, and data is the affected entity in the shape the Paddle API returns. Entity ids are prefixed by type: txn_, sub_, ctm_, pri_.

What a Paddle webhook request looks like

The request line and headers as they reach your server, followed by the body. Ids, tokens and the signature are placeholders.

POST /your/webhook/path HTTP/1.1
Host: your-server.example
Paddle-Signature: ts=1767225600;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151
Content-Type: application/json
User-Agent: Paddle
{
  "event_id": "evt_01hexample000000000000000001",
  "event_type": "transaction.completed",
  "occurred_at": "2026-01-01T12:00:00.000000Z",
  "notification_id": "ntf_01hexample000000000000000002",
  "data": {
    "id": "txn_01hexample000000000000000003",
    "status": "completed",
    "origin": "web",
    "customer_id": "ctm_01hexample000000000000000004",
    "subscription_id": "sub_01hexample000000000000000005",
    "currency_code": "USD",
    "collection_mode": "automatic",
    "items": [
      {
        "price": {
          "id": "pri_01hexample000000000000000006",
          "product_id": "pro_01hexample000000000000000007"
        },
        "quantity": 1
      }
    ],
    "details": {
      "totals": {
        "subtotal": "2000",
        "tax": "0",
        "total": "2000",
        "currency_code": "USD"
      }
    },
    "custom_data": {
      "order_id": "6735"
    },
    "billed_at": "2026-01-01T11:59:58.000000Z"
  }
}

Totals are strings holding an integer in the currency's smallest unit, so "2000" is 20.00 USD; parse them deliberately rather than relying on a number type. custom_data returns whatever you attached at checkout, which is the dependable link back to your own order or user. One event_id can be delivered under several notification_id values if it is sent to more than one destination.

Headers Paddle sends

HeaderExampleWhat it is
Paddle-Signaturets=1767225600;h1=eb4d0dc8853be92b7f063b9f3b…Timestamp and HMAC-SHA256 signature, hex, separated by a semicolon.
Content-Typeapplication/jsonThe body is JSON.
User-AgentPaddleIdentifies the sender.

Header names are case-insensitive. Your framework may show them lower-cased, and HTTP/2 always does.

How Paddle signs a webhook

Each notification destination has its own secret key. Paddle joins the timestamp and the raw body with a colon — {ts}:{raw body} — computes an HMAC-SHA256 with that key, and sends ts=…;h1=… in Paddle-Signature, with the hex digest as h1.

Two details differ from the otherwise similar Stripe scheme: the header's parts are separated by a semicolon, and the signed string uses a colon where Stripe uses a full stop. After the HMAC matches, reject a request whose ts is too far from your clock; Paddle's own SDKs use a tolerance of a few seconds. Compute it over the raw request body exactly as received — before any JSON parsing — and compare with a constant-time function.

const crypto = require('crypto');

// rawBody: string of the body exactly as received
function verifyPaddle(rawBody, header, secretKey, toleranceSec = 30) {
  const parts = Object.fromEntries(String(header).split(';').map((p) => p.split('=')));
  if (!parts.ts || !parts.h1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.ts)) > toleranceSec) return false;

  const expected = crypto.createHmac('sha256', secretKey)
    .update(parts.ts + ':' + rawBody).digest('hex');
  const a = Buffer.from(parts.h1);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Response time, retries and replays

Respond with a 200 within about five seconds, per Paddle's documentation at the time of writing, and do the real work afterwards. A notification that is not acknowledged is retried with a growing delay: for days on a live account, and only a few times over a short period in the sandbox, according to the same documentation.

Notifications can arrive out of order and more than once. Store event_id to ignore repeats, and use occurred_at to decide which of two updates to the same subscription is the later one. Paddle keeps a log of notifications per destination, and a failed one can be replayed from the dashboard or through the API.

Catch a real Paddle webhook

  1. Press Create webhook URL at the top of this page and copy the address.
  2. Sign in to the Paddle sandbox and open Developer tools → Notifications.
  3. Create a destination of the URL type, paste the address, and choose events such as transaction.completed.
  4. Use the webhook simulator to send that event, or complete a sandbox checkout with a test card.
  5. Read the notification and the Paddle-Signature header in the inbox above.

The destination's secret key is shown when you edit it. Sandbox and live are separate accounts with separate destinations and keys.

To run a caught request against your own code, select it and press Open in API Tester: the method, headers and body are loaded into the request console, where you enter your server's public URL and send it. A body you edit will no longer match its signature — that is the signature doing its job.

Things that catch people out

  • A Stripe header parser will not work. Split on ;, and sign ts:body, not ts.body.
  • Simulated events carry made-up ids. A simulator payload verifies correctly, but its entities cannot be fetched from the API.
  • Paddle Classic is another format entirely. The older product sends form-encoded alerts verified with a public key; none of this page applies to it.

api-tester.com is independent and is not affiliated with Paddle. Providers change header names, limits and retry schedules; treat the official Paddle documentation as the source of truth.

Related examples and tools

Other webhooks with a timestamp in the signature

References

These links leave api-tester.com, which is independent of the companies and projects they point to.

Paddle webhook FAQ

What does a Paddle webhook payload look like?

A JSON object with event_id, event_type, occurred_at, notification_id and data. data holds the entity the event is about, such as a transaction or subscription, in the same shape the Paddle API returns.

How do I verify the Paddle-Signature header?

Split the header on semicolons to get ts and h1, compute an HMAC-SHA256 of ts, a colon and the raw body using the destination secret key, hex-encode it and compare with h1. Reject timestamps that are not recent.

Does Paddle retry failed webhooks?

Yes. Unacknowledged notifications are retried with increasing delays, over days on live accounts and over a much shorter period in the sandbox, according to Paddle's documentation at the time of writing.

How do I test Paddle webhooks?

Create a notification destination in the sandbox and use the webhook simulator to send events to it, or run a sandbox checkout with a test card.

Why are amounts strings in Paddle webhooks?

Paddle represents money as a string containing an integer in the smallest currency unit, which avoids floating-point rounding. Convert it with care for the currency's number of decimal places.