GitHub webhooks are easy to configure and miserable to test. The moment you add a webhook, GitHub needs a publicly reachable Payload URL — and your dev server on localhost:3000 is not that. So you end up deploying half-finished handler code to a staging box, tailing remote logs, and pushing a commit every time you want to see one more payload. That loop is slow, and it hides the thing you actually want: the raw event body and headers, on your machine, in a debugger.
This guide shows you how to receive real GitHub webhooks on localhost with Relayers. You get a stable public ingest URL, a tunnel to your dev machine, and rules to filter and reshape events before they ever hit your code. Signatures stay intact, so your X-Hub-Signature-256 verification works locally exactly like it does in production.
What you'll build
GitHub only ever talks to the stable Relayers URL. Relayers forwards matching events down the tunnel to whatever port your handler is listening on.
Prerequisites
- A GitHub repository (or org) where you can manage webhooks.
- A local HTTP server that accepts POST requests, e.g. on
localhost:3000. - The Relayers desktop app or CLI installed. Grab it from relayers.app — the free tier covers this workflow.
1Create an endpoint in Relayers
Open the desktop app, go to Endpoints, and create a new one. Relayers gives you an ingest URL that looks like this:
https://api.relayers.app/v1/webhooks/wep_8fK2p...
Copy it — that's your Payload URL for GitHub. Before touching GitHub at all, hit Send test from the endpoint view to confirm the endpoint is live and shows up in your event stream.
2Add the webhook in GitHub
In your repository, go to Settings → Webhooks → Add webhook and fill in:
- Payload URL: paste your Relayers endpoint URL (
https://api.relayers.app/v1/webhooks/wep_<id>). - Content type:
application/json. - Secret: set a strong secret. GitHub uses it to sign every delivery with HMAC-SHA256.
- Which events: choose Let me select individual events, then pick the ones you care about — for example
push,pull_request,issues,release.
Save it. GitHub immediately sends a ping event to verify the URL is reachable. You should see it land in Relayers within a second or two.
Two headers matter on every delivery:
X-GitHub-Event— the event name (push,pull_request, etc.). Use it to branch your handler logic.X-Hub-Signature-256—sha256=<hmac>, the HMAC-SHA256 of the raw request body signed with your secret.
Relayers forwards both of these untouched.
3Forward events to localhost
Log in from the CLI, then start a tunnel to your local port:
relayers login
relayers listen --forward localhost:3000
Now trigger something in the repo — push a commit, open a pull request — and the event flows GitHub → Relayers → your tunnel → localhost:3000. Every delivery also shows up under Events with a full payload inspector, so you can see exactly what arrived even when your handler throws.
4Route and filter with JQ
You rarely want every event hitting your handler. Under Rules on the endpoint, add JQ-style filters. Rules evaluate top to bottom and first match wins, and you can reorder them.
To forward only newly opened pull requests, filter on the payload's action field:
{ "action": "opened" }
Anything that doesn't match opened — synchronize, closed, reopened — gets dropped before it reaches your code.
You can also split event types across different local services by adding two rules with different destinations:
- Rule 1 — match
pushevents → destination tunnellocalhost:3000 - Rule 2 — match
pull_requestevents → destination tunnellocalhost:3001
Now your CI-style push handler and your PR bot run as separate processes on separate ports, each fed only the events it cares about. Destinations can also be public URLs if you want to fan out to a deployed service at the same time.
5Transform the payload
GitHub payloads are large. If your handler only needs a few fields, reshape the event in Transformations before it hits your app. Relayers supports jq, JSONata, JavaScript, and Go templates, with a test playground so you can iterate against a real captured payload.
A jq transform that flattens a pull_request event into just what you need:
{
repo: .repository.full_name,
pr: .number,
title: .pull_request.title,
author: .pull_request.user.login
}
Your handler receives a tidy object instead of a few hundred lines of JSON. Run it against a saved payload in the playground until the output looks right, then attach it to the endpoint.
A note on signatures
Because Relayers forwards the original body and headers untouched, X-Hub-Signature-256 still validates on your machine. Recompute HMAC-SHA256(secret, rawBody) in your handler and compare it against the header value, exactly as you would in production. There's no separate "local mode" to special-case — verification works locally the same way it works when deployed.
One caveat: if you apply a Relayers transform, the reshaped payload will no longer match the original signature — that's expected, since the signature covers GitHub's exact bytes. Verify against the raw body before transforming.
Going to production
When you ship, keep the endpoint. Point one rule's destination at your production URL instead of the tunnel, and the same filters and transforms apply. Use Import/Export to move the whole endpoint, rules, and transformations between environments as a JSON bundle, so your staging and prod setups stay in sync without clicking through the UI twice.
Troubleshooting
- No events arriving? In GitHub, open the webhook's Recent Deliveries tab. Every attempt is logged there with its request and response. Hit Redeliver to replay a payload without having to re-trigger the underlying action.
- Test the plumbing with
ping. GitHub'spingevent fires when you first create the webhook. If you see thepingin Relayers but nothing else, your event selection in GitHub is probably too narrow. - Signature mismatch? The secret in GitHub must exactly match the secret your handler uses to recompute the HMAC. A trailing space or a stale value is the usual culprit. Confirm you're hashing the raw body bytes, not a parsed-and-reserialized object.
- Handler errors? Check the Events view in Relayers and use retry to re-run a delivery against your localhost after you fix the bug — no need to poke GitHub again.
Wrap-up
You now have real GitHub webhooks landing on localhost: a stable public endpoint, a tunnel to your dev machine, JQ rules to filter and route push versus pull_request, transformations to trim the payload, and intact signatures so verification behaves identically in dev and prod. No staging deploys, no commit-and-pray loop.
Ready to stop deploying just to read a payload? Get started at relayers.app and download the app.