Creating a notification destination
Create a URL above, then in Paddle open Developer tools → Notifications → New destination. Paste the URL, choose Webhook as the type, select events, and save. Paddle shows the secret key for that destination once — copy it, because verification needs it and you cannot see it again afterwards.
Start in the sandbox. It is a separate account with its own dashboard, its own secret keys, and test card numbers, and it fires genuine notifications.
To make something happen, complete a sandbox checkout, or press Simulate on the notification destination — Paddle can send a synthetic event of any type, which is the fastest way to see a real signature without a payment.
Reading the envelope
{
"event_id": "evt_01hfake000000000000000000",
"event_type": "transaction.completed",
"occurred_at": "2026-06-15T10:04:00.123456Z",
"notification_id": "ntf_01hfake000000000000000000",
"data": { ... }
}
Switch on event_type; the resource is under data. Paddle’s ids are prefixed ULIDs — txn_, sub_, ctm_, pri_ — which makes them pleasant to read in logs and easy to validate.
The two id fields do different jobs. event_id is the event; notification_id is this delivery of it. If you have two destinations, both get the same event_id. Deduplicate on that one.
Totals live under details
"details": { "totals": { "subtotal": "2900", "tax": "580", "total": "3480", "currency_code": "USD" } }
Strings, in minor units. "3480" is $34.80. This is deliberate — JSON numbers are IEEE-754 doubles and Paddle would rather hand you an exact string than a value your language might round. It does mean you cannot sum these without parsing:
const cents = Number.parseInt(totals.total, 10); // 3480
custom_data is where your own identifiers go. Set it at checkout and it comes back on every event about that transaction or subscription — the only reliable join back to your user table.
Verifying Paddle-Signature
Paddle-Signature: ts=1750000000;h1=3f9d2a7c...
Split on ;, then on =. Build the signed string as the timestamp, a colon, and the raw body:
1750000000:{"event_id":"evt_...","event_type":"transaction.completed",...}
HMAC-SHA256 with the destination’s secret key, hex-encode, compare with h1 in constant time. In the Signature tab above, choose Paddle and paste the key.
The construction is Stripe’s with two characters changed: semicolon instead of comma between the header fields, colon instead of a dot in the signed string. Everything else — SHA-256, hex, timestamp-first — is the same. That similarity is a trap worth naming, because a mis-ported verifier fails silently and looks like a wrong secret.
Reject stale timestamps as well. Paddle recommends a five-second tolerance for the signature check itself; a five-minute window is more forgiving of clock skew and still closes the replay hole.
Subscription lifecycle in practice
The event you provision on and the event you revoke on are not symmetric, and getting this wrong is the most common Paddle billing bug:
subscription.created with status: "trialing" — grant access, but mark it as a trial.
subscription.updated with scheduled_change.action: "cancel" — the customer has asked to cancel. Access continues until scheduled_change.effective_at.
subscription.canceled — now revoke.
transaction.payment_failed — do nothing to access. Paddle retries and dunns on its own schedule, and cutting someone off on the first failed renewal loses customers whose card simply expired.