# Test GitHub webhooks on localhost

GitHub delivers webhooks to a public URL. Expose gives your local server one, labels each delivery with its event name, and lets you replay a delivery while you fix your handler.

## 1. Share your local server

```bash
expose share http://localhost:3000
```

Expose prints a public URL such as `https://abc123.eu-1.sharedwithexpose.com`. With [Expose Pro](https://expose.dev/#pricing), add `--subdomain=my-app` to keep the same URL between sessions.

## 2. Add the webhook in GitHub

For a repository:

1. Go to **Settings → Webhooks → Add webhook**.
2. Set **Payload URL** to your Expose URL plus your route, for example `https://my-app.eu-1.sharedwithexpose.com/github/webhook`.
3. Set **Content type** to `application/json`.
4. Enter a **Secret**. Use a long random string and store it in your app's environment.
5. Choose the events you want and save.

For a GitHub App, set the same URL and secret as the **Webhook URL** and **Webhook secret** in the app's settings.

GitHub sends a `ping` event right away. It shows up in the Expose dashboard at `http://127.0.0.1:4040`.

## 3. Verify the signature

GitHub signs each delivery with your secret and sends the result in the `X-Hub-Signature-256` header, as `sha256=` followed by an HMAC of the raw request body. Compute the same HMAC in your handler and compare the two with a constant-time comparison.

The event name is in the `X-GitHub-Event` header. Expose forwards both headers and the body unchanged.

## 4. Inspect and replay

Push a commit or open a pull request. In the Expose dashboard you see each delivery with its headers, payload and your response. The built-in GitHub [request plugin](https://expose.dev/docs/advanced-usage/request-plugins) labels deliveries with their event, such as `push` or `pull_request`.

Click **Replay** to send a delivery to your local server again after you change your code. GitHub can also redeliver from the webhook's **Recent Deliveries** tab.

## Troubleshooting

- **GitHub shows a failed delivery.** Check that Expose is still running and that the Payload URL matches the current Expose URL. Free URLs change on each connection.
- **The signature doesn't match.** Verify against the raw body, not a re-encoded JSON string, and check that the content type is `application/json`.
- **You only want to look at payloads.** Run `expose catch-all` and use its URL as the Payload URL. See [Catch-All](https://expose.dev/docs/client/catch-all).
