Skip to content
TryWebhook
New URL

Payments · Free · No signup

Test and debug Paddle webhooks

Point a Paddle Billing notification destination at a throwaway URL, trigger a transaction, and read the delivery as it arrived — including the timestamped Paddle-Signature you verify with your notification secret key.

No signup · No email · Ready in about a second

At a glance

Paddle webhooks in brief

Category
Payments
Where to configure
Paddle → Developer tools → Notifications → New destination
Signature header
Paddle-Signature
Secret
Secret key (starts with `pdl_ntfset_`)
Retries
Retried with backoff for up to three days. Individual events can also be replayed from the Notifications screen, which is rarer and more useful than it sounds.
Response deadline
5 seconds. Paddle marks a slower response as a failed attempt.
Treated as success
Any 2xx. Redirects are not followed.

Cross-checked against Paddle's own webhook documentation. Providers change these; if something here is stale, tell us.

The payload

What Paddle actually sends

Shape-accurate, with obviously fake identifiers. These are the same samples the inspector's Send test webhook panel fires, so you can read one here and then send the identical bytes to your own URL.

POST/w/{your-bin-id}668 B

Paddle Billing. Money is a string of minor units, not a number.

1 headers
user-agent
Paddle/1.0
Body · application/json · 1 line
{"event_id":"evt_01hfake000000000000000000","event_type":"transaction.completed","occurred_at":"2026-06-15T10:04:00.123456Z","notification_id":"ntf_01hfake000000000000000000","data":{"id":"txn_01hfake000000000000000000","status":"completed","customer_id":"ctm_01hfake000000000000000000","subscription_id":"sub_01hfake000000000000000000","currency_code":"USD","origin":"subscription_recurring","billed_at":"2026-06-15T10:03:58.000000Z","items":[{"price":{"id":"pri_01hfake000000000000000000","description":"Pro — monthly"},"quantity":1}],"details":{"totals":{"subtotal":"2900","tax":"580","total":"3480","currency_code":"USD"}},"custom_data":{"user_id":"user_1234"}}}

No sample carries a signature header. A valid one can only be produced with your own signing secret, and a fabricated one would make the Signature tab report a mismatch that isn't real. Send a genuine delivery when you want to test verification.

Verification

Verifying a Paddle signature

Signed payload

The header carries two fields, `ts=1750000000;h1=<hex>`. The `h1` value is an HMAC-SHA256 of the string `{ts}:{raw body}`, keyed with the notification destination's secret key.

Implemented as: HMAC-SHA256 over `{ts}:{raw body}`, hex, read from the `h1` field.

Built in. Open any captured request, switch to the Signature tab, pick Paddle (Billing), and paste your secret key (starts with `pdl_ntfset_`). The HMAC is computed in your browser with the Web Crypto API over the raw bytes that arrived — the secret is never sent to us, and never written to disk.

Events

Events worth subscribing to first

Not the full catalogue — the handful that carry most integrations. Subscribing to everything is the fastest way to a handler that times out.

EventFires when
transaction.completedA transaction was paid and finalised. The usual provisioning trigger for both one-off purchases and renewals.
subscription.createdA new subscription exists. Note that it can be `trialing` rather than `active` — check `status` before granting paid features.
subscription.updatedPlan, quantity, status, or scheduled change altered. The event to sync entitlements from, because it is the one that fires for cancellations at period end.
subscription.canceledThe subscription has actually ended. A cancellation *requested* earlier arrives as `subscription.updated` with `scheduled_change` set.
transaction.payment_failedA charge attempt failed. Paddle handles dunning itself, so treat this as information rather than a signal to revoke access immediately.

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.

Failure modes

Where Paddle integrations usually break

Paddle Classic is a different product

Paddle Classic sent form-encoded bodies signed with RSA and a `p_signature` field. Paddle Billing sends JSON signed with an HMAC in the `Paddle-Signature` header. They share a brand and nothing else. Every verification guide older than 2023 is about the wrong one.

The separator is a semicolon

`ts=...;h1=...` — semicolon between fields, and the signed string joins them with a colon: `{ts}:{body}`. Stripe uses a comma between fields and a dot in the signed string. Porting a Stripe verifier without changing both characters produces a mismatch with no useful error.

Money is a string of minor units

`"total": "3480"` is $34.80 — a string, and in cents. Paddle uses strings to avoid float precision loss, which means arithmetic on it needs an explicit parse and a decimal type, not a `+`.

Sandbox and live have separate secrets

The sandbox is a completely separate account with its own notification destinations and its own secret keys. A working sandbox integration tells you nothing about whether the live secret is configured.

Fields are omitted, not null

Paddle leaves out keys that do not apply rather than sending null. `subscription_id` is simply absent on a one-off transaction, so optional chaining is not defensive — it is required.

Answers

Paddle webhook questions

Is this for Paddle Billing or Paddle Classic?+

Paddle Billing. The signature scheme, the JSON body, and the `Paddle-Signature` header are all Billing-specific. Classic used a form-encoded body and an RSA signature in a `p_signature` field, and the built-in verifier here does not implement it.

Can I test without making a real payment?+

Yes. The Paddle sandbox issues test cards and fires real notifications with real signatures against your sandbox secret key. You can also press Replay on any past event in the Notifications screen.

Can I verify the Paddle-Signature here?+

Yes. Choose Paddle in the Signature tab and paste the destination's `pdl_ntfset_` secret key. The `ts` field is read from the header and folded into the signed string for you.

What is the difference between notification_id and event_id?+

`event_id` identifies the event that occurred; `notification_id` identifies this delivery of it to this destination. Two destinations receive the same `event_id` with different `notification_id` values. Deduplicate on `event_id`.

Why did a cancellation arrive as subscription.updated?+

Because Paddle distinguishes requesting a cancellation from the subscription ending. The request sets `scheduled_change` and fires `subscription.updated`; `subscription.canceled` fires later, when the period actually ends. Revoking access on the first one cuts customers off early.

Other providers

Testing something else?

One click

Point Paddle at a URL and watch the payload land.

Paddle → Developer tools → Notifications → New destination — paste the URL, trigger an event, and read exactly what arrived.

No signup · No email · Ready in about a second