Test Paddle webhooks on localhost
#

Paddle Billing sends webhooks to a public HTTPS URL. Expose gives your local server one, labels every Paddle event with its type, and lets you replay an event without going through another test checkout. We use it ourselves: Expose's own billing runs on Paddle.

1. Share your local server
#

expose share http://localhost:3000

Expose prints a public URL such as https://abc123.eu-1.sharedwithexpose.com. If you use Laravel Herd or Valet, run expose in the project directory instead.

With Expose Pro you can keep the same URL every time, so you only create the notification destination once:

expose share http://localhost:3000 --subdomain=my-shop

2. Add a notification destination in Paddle
#

  1. Open your Paddle sandbox dashboard and go to Developer Tools → Notifications.
  2. Create a new destination of type Webhook (Paddle calls it a URL destination).
  3. Enter your Expose URL followed by your webhook route, for example https://my-shop.eu-1.sharedwithexpose.com/paddle/webhook.
  4. Select the events you need, such as transaction.completed, subscription.created and subscription.updated.
  5. Save, then copy the destination's secret key. It starts with pdl_ntfset_.

3. Verify the signature in your app
#

Paddle signs every webhook and sends the signature in the Paddle-Signature header:

Paddle-Signature: ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151

To verify it:

  1. Split the header into the timestamp ts and the signature h1.
  2. Build the signed payload by joining the timestamp, a colon and the raw request body: ts:body.
  3. Compute an HMAC-SHA256 of that payload with your destination's secret key.
  4. Compare the result with h1 using a constant-time comparison.

Paddle's SDKs do this for you. Verification needs the raw request body, so read it before a JSON parser changes it. Expose forwards the body and the Paddle-Signature header unchanged.

With Laravel Cashier for Paddle, set PADDLE_WEBHOOK_SECRET to the secret key. Cashier registers the /paddle/webhook route and verifies the signature for you.

4. Trigger an event and inspect it
#

Complete a checkout in the sandbox with one of the test cards from the Paddle docs, or send a simulated event from Developer Tools → Simulations to your destination. Then open the Expose dashboard:

http://127.0.0.1:4040

You see every request with its headers, body and your application's response. The built-in Paddle Billing request plugin shows the event_type of each request in the terminal and in the dashboard:

200    POST  /paddle/webhook ............ payment_method.saved  14:51:04  53 ms
200    POST  /paddle/webhook ............ transaction.completed 14:51:03  61 ms
200    POST  /paddle/webhook ............ transaction.updated   14:51:02  65 ms

5. Replay instead of buying again
#

When your handler has a bug, fix the code and click Replay on the request in the dashboard. Expose sends the same event to your local server again, so you don't need another test checkout. You can also change the request before replaying it.

If your code rejects webhooks whose ts is too old, allow a larger time difference in your local environment while you replay older events.

Troubleshooting
#

  • Signature verification fails. Use the secret key of this notification destination, not your API key, and make sure sandbox and live secrets aren't mixed up. Verify against the raw body.
  • Paddle retries the webhook. Paddle expects a 200 response within five seconds. Respond first and do slow work in a queue.
  • The URL changed. Free Expose URLs are random on each connection. Use --subdomain with Expose Pro, or update the destination in Paddle.
  • You only want to see the payloads. Run expose catch-all and use its URL as the destination. See Catch-All.