Test Paddle webhooks without deploying
Capture Paddle Billing transaction and subscription events, and verify the ts/h1 Paddle-Signature header.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Paddle at Paddle dashboard, Developer tools, Notifications, then trigger something. Events worth starting with:
transaction.completedsubscription.createdsubscription.canceledadjustment.updated
Verify the Paddle-Signature header#
Paddle signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA256 over the timestamp, a colon, then the raw body, hex-encoded, keyed with your notification destination secret key.
- Header
Paddle-Signature- Value format
ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151- Algorithm
- HMAC-SHA256, hex
- Signed content
- the timestamp, a colon, then the raw body
const crypto = require("crypto");
const parts = Object.fromEntries(
req.headers["paddle-signature"].split(";").map((p) => p.split("=")),
);
// Paddle signs "{ts}:{rawBody}" with a colon, where Stripe uses a full stop.
const expected = crypto
.createHmac("sha256", process.env.PADDLE_NOTIFICATION_SECRET)
.update(`${parts.ts}:${rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.h1));crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The separator is a semicolon between parts and a colon inside the signed string. Stripe's scheme looks similar and uses a comma and a full stop, so the two are easy to confuse.
- h1 can appear more than once while a secret is being rotated.
- The body must not be parsed, reformatted or have whitespace added before verification.
- This is Paddle Billing. Paddle Classic used a different, RSA-based scheme, so older tutorials do not apply.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Paddle event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Paddle handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "transaction.completed" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Paddle’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.