# 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

```bash
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](https://expose.dev/#pricing) you can keep the same URL every time, so you only create the notification destination once:

```bash
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](https://expose.dev/docs/advanced-usage/request-plugins) 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](https://expose.dev/docs/client/catch-all).
