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
| Header | Example | What it is |
|---|---|---|
Paddle-Signature | ts=1767225600;h1=eb4d0dc8853be92b7f063b9f3b… | Timestamp and HMAC-SHA256 signature, hex, separated by a semicolon. |
Content-Type | application/json | The body is JSON. |
User-Agent | Paddle | Identifies 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
- Press Create webhook URL at the top of this page and copy the address.
- Sign in to the Paddle sandbox and open Developer tools → Notifications.
- Create a destination of the URL type, paste the address, and choose events such as
transaction.completed. - Use the webhook simulator to send that event, or complete a sandbox checkout with a test card.
- Read the notification and the
Paddle-Signatureheader 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 signts:body, notts.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
Razorpay Webhook Example
Razorpay wraps each event's entities inside payload under their own names, and signs the body with a secret you choose when creating the webhook.
Twitch Webhook Example
Twitch EventSub sends three kinds of message to the same URL, told apart by a header: a challenge to prove you own the endpoint, the notifications themselves, and a revocation when a subscription ends.
Zoom Webhook Example
Zoom will not save a webhook endpoint until it has answered a validation challenge with a hash made from the app's Secret Token, and it repeats that check from time to time.
WhatsApp Webhook Example
WhatsApp Cloud API webhooks come from Meta's shared webhook system: a GET challenge when you register the URL, then deeply nested JSON for every incoming message and every delivery status.
Dropbox Webhook Example
A Dropbox webhook tells you who has changes and nothing about what changed.
Stripe Webhook Example
The scheme Paddle's resembles: a timestamped HMAC with a comma-separated header.
Curl POST JSON
Send a JSON body like this one from a terminal.
Webhook Tester
The inbox on its own page, plus a guide to signatures, retries and idempotent handlers.
All Webhook Examples
30 providers side by side: content type, signature scheme and retry behaviour.
Other webhooks with a timestamp in the signature
References
- Official Paddle documentation — The provider's own guide to the requests described on this page.
- Paddle Developer: verify webhook signatures — The Paddle-Signature check, with and without the SDK.
- Paddle Developer: handle webhook delivery — How to respond, and what happens when you do not.
- RFC 2104: HMAC — The keyed-hash construction this signature is built on.
- Node.js: crypto.timingSafeEqual() — The constant-time comparison used in the code on this page.
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.