Test GitHub App Webhooks Locally with Node.js, Express, and taupi
Build and test GitHub App webhooks locally in Express with taupi tunnels, HMAC signature verification, and GitHub UI redelivery.
Introduction
GitHub Apps are a great way to automate repository workflows: label new issues, react to pull requests, sync repository metadata, enforce internal policies, or trigger deployments. Most GitHub Apps are event-driven, which means your app receives webhooks from GitHub whenever something interesting happens.
That creates a practical development problem: GitHub needs to deliver an HTTPS request to your webhook endpoint, but your Node.js/Express app is usually running on localhost:3000. GitHub cannot reach localhost on your laptop.
This guide walks through a complete local development setup for testing GitHub App webhooks in a Node.js/Express app using taupi to expose your local server through a secure public tunnel. We will also implement proper GitHub webhook signature verification using X-Hub-Signature-256, inspect deliveries in the GitHub UI, and redeliver the same webhook after you change your code.
By the end, you will have:
- A working Express webhook endpoint
- HMAC SHA-256 signature verification using your GitHub App webhook secret
- Event handling for
ping,issues,pull_request, andinstallation - A public HTTPS URL pointing to your local server through taupi
- A repeatable workflow for debugging failed webhooks and redelivering them from GitHub
How GitHub App webhooks work locally
A GitHub App webhook request is a normal HTTP POST request. GitHub sends headers such as:
X-GitHub-Event: the event name, for exampleissuesorpull_requestX-GitHub-Delivery: a unique delivery ID for tracing and idempotencyX-Hub-Signature-256: an HMAC signature generated from the raw request body and your webhook secretUser-Agent: usually something likeGitHub-Hookshot/...
The important detail is that signature verification must use the raw request body exactly as GitHub sent it. If Express parses JSON first, reformats the object, changes whitespace, or changes encoding, the signature check can fail even though the payload data looks correct.
The local development flow looks like this:
- Start your Express app on
localhost:3000. - Start a taupi tunnel to expose port
3000publicly. - Put the public taupi HTTPS URL into your GitHub App webhook settings.
- Trigger an event in a test repository.
- Inspect logs locally and inspect the delivery in GitHub.
- Fix code and use GitHub's Redeliver button to replay the webhook.
Prerequisites
You will need:
- Node.js 18 or newer
- npm
- A GitHub account
- Permission to create a GitHub App under your user or organization
- A test repository where you can install the app
You do not need to deploy anything to the cloud for this tutorial.
1. Create a minimal Express webhook server
Start by creating a new project:
mkdir github-app-webhooks-local
cd github-app-webhooks-local
npm init -y
npm install express dotenv
Create a .env file:
PORT=3000
GITHUB_WEBHOOK_SECRET=replace-this-with-a-long-random-secret
Generate a strong secret with OpenSSL:
openssl rand -hex 32
Copy the generated value into .env:
GITHUB_WEBHOOK_SECRET=8f1c...your-generated-secret...e92a
Now create index.js:
require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const app = express();
const port = Number(process.env.PORT || 3000);
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('Missing GITHUB_WEBHOOK_SECRET in environment');
}
// In development, this helps avoid processing the same GitHub delivery twice.
// In production, store processed delivery IDs in a database or cache.
const seenDeliveries = new Set();
function verifyGitHubSignature(req, res, next) {
const signature = req.get('x-hub-signature-256');
if (!signature) {
return res.status(401).json({ error: 'Missing X-Hub-Signature-256 header' });
}
if (!signature.startsWith('sha256=')) {
return res.status(401).json({ error: 'Invalid signature format' });
}
const expectedSignature =
'sha256=' +
crypto
.createHmac('sha256', webhookSecret)
.update(req.body)
.digest('hex');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
const actualBuffer = Buffer.from(signature, 'utf8');
if (expectedBuffer.length !== actualBuffer.length) {
return res.status(401).json({ error: 'Invalid signature length' });
}
const isValid = crypto.timingSafeEqual(expectedBuffer, actualBuffer);
if (!isValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
next();
}
function parsePayload(req, res, next) {
try {
req.payload = JSON.parse(req.body.toString('utf8'));
next();
} catch (error) {
res.status(400).json({ error: 'Invalid JSON payload' });
}
}
app.get('/health', (req, res) => {
res.json({ ok: true });
});
// Important: use express.raw() for this route so signature verification receives
// the exact bytes GitHub signed. Do not put express.json() before this route.
app.post(
'/github/webhooks',
express.raw({ type: 'application/json' }),
verifyGitHubSignature,
parsePayload,
async (req, res) => {
const event = req.get('x-github-event');
const delivery = req.get('x-github-delivery');
const payload = req.payload;
console.log('\n--- GitHub webhook received ---');
console.log('event:', event);
console.log('delivery:', delivery);
console.log('action:', payload.action);
// GitHub redelivery is useful during debugging, but real webhook handlers
// should be idempotent. This protects you from double-processing.
if (delivery && seenDeliveries.has(delivery)) {
console.log('duplicate delivery ignored:', delivery);
return res.status(202).json({ ok: true, duplicate: true });
}
if (delivery) {
seenDeliveries.add(delivery);
}
try {
switch (event) {
case 'ping':
handlePing(payload);
break;
case 'issues':
handleIssues(payload);
break;
case 'pull_request':
handlePullRequest(payload);
break;
case 'installation':
handleInstallation(payload);
break;
default:
console.log(`Unhandled event: ${event}`);
}
// Return a 2xx quickly. For expensive work, enqueue a job and respond.
res.status(202).json({ ok: true });
} catch (error) {
console.error('webhook handler failed:', error);
res.status(500).json({ error: 'Webhook handler failed' });
}
}
);
function handlePing(payload) {
console.log('ping received');
console.log('hook id:', payload.hook_id);
console.log('repository:', payload.repository?.full_name || '(app-level ping)');
}
function handleIssues(payload) {
const issue = payload.issue;
const repo = payload.repository;
console.log(`issue ${payload.action}: ${repo.full_name}#${issue.number}`);
console.log('title:', issue.title);
console.log('author:', issue.user.login);
}
function handlePullRequest(payload) {
const pr = payload.pull_request;
const repo = payload.repository;
console.log(`pull_request ${payload.action}: ${repo.full_name}#${pr.number}`);
console.log('title:', pr.title);
console.log('head:', pr.head.ref);
console.log('base:', pr.base.ref);
}
function handleInstallation(payload) {
console.log(`installation ${payload.action}`);
console.log('installation id:', payload.installation?.id);
console.log('account:', payload.installation?.account?.login);
}
app.listen(port, () => {
console.log(`Webhook server listening on http://localhost:${port}`);
console.log(`Webhook path: /github/webhooks`);
});
Start the server:
node index.js
In another terminal, test the health endpoint:
curl http://localhost:3000/health
You should see:
{"ok":true}
2. Why raw body parsing matters
A common mistake is this:
app.use(express.json());
app.post('/github/webhooks', verifyGitHubSignature, (req, res) => {
// Too late: req.body is already a parsed object.
});
That breaks signature verification because GitHub signs the raw bytes, not the parsed JavaScript object. The correct approach is to use express.raw() on the webhook route before parsing JSON:
app.post(
'/github/webhooks',
express.raw({ type: 'application/json' }),
verifyGitHubSignature,
parsePayload,
handler
);
If your app has other JSON API routes, register express.json() after the webhook route or scope it away from /github/webhooks:
app.post('/github/webhooks', express.raw({ type: 'application/json' }), handler);
app.use(express.json());
3. Test signature verification without GitHub
Before configuring your GitHub App, it is useful to prove your local route rejects unsigned requests and accepts correctly signed requests.
Create scripts/send-test-webhook.js:
mkdir -p scripts
// scripts/send-test-webhook.js
require('dotenv').config();
const crypto = require('crypto');
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('Missing GITHUB_WEBHOOK_SECRET');
}
const payload = JSON.stringify({
zen: 'Keep it logically awesome.',
hook_id: 123,
action: undefined,
});
const signature =
'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
async function main() {
const response = await fetch('http://localhost:3000/github/webhooks', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-github-event': 'ping',
'x-github-delivery': crypto.randomUUID(),
'x-hub-signature-256': signature,
},
body: payload,
});
console.log('status:', response.status);
console.log(await response.text());
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Run it while your server is running:
node scripts/send-test-webhook.js
You should receive a 202 response and see a ping event in your Express logs.
Now test a bad signature:
curl -i -X POST http://localhost:3000/github/webhooks \
-H 'content-type: application/json' \
-H 'x-github-event: ping' \
-H 'x-github-delivery: test-delivery' \
-H 'x-hub-signature-256: sha256=bad' \
--data '{"zen":"bad signature"}'
Your app should return 401.
4. Expose localhost with taupi
Install taupi if you do not already have it:
curl -fsSL https://taupi.dev/install.sh | bash
Start your Express app:
node index.js
In another terminal, create a public tunnel to port 3000:
taupi tunnel 3000
The command prints a public HTTPS URL that forwards to your local server. It will look similar to:
Forwarding https://your-random-url.taupi.dev -> http://localhost:3000
Your GitHub App webhook URL will be that public URL plus the route path:
https://your-random-url.taupi.dev/github/webhooks
The free taupi tier is enough for quick tests: one tunnel, five-minute sessions, and no signup required. For longer GitHub App development sessions, a persistent URL is convenient because you do not have to update your GitHub App settings every time the tunnel restarts. With Pro, authenticate and request a custom subdomain:
taupi auth <api-key>
taupi tunnel 3000 --subdomain my-github-app-dev
Then your webhook URL can stay stable:
https://my-github-app-dev.taupi.dev/github/webhooks
5. Create or configure your GitHub App
In GitHub:
- Open Settings from your profile menu.
- Go to Developer settings.
- Select GitHub Apps.
- Click New GitHub App or edit an existing app.
Use these values for local testing:
- GitHub App name: any unique name, for example
Local Webhook Test App - Homepage URL:
http://localhost:3000is fine for development - Webhook: enabled
- Webhook URL: your taupi URL plus
/github/webhooks - Webhook secret: exactly the same value as
GITHUB_WEBHOOK_SECRETin.env
Under Permissions, choose the minimum permissions needed for the events you want to test. For example:
- Repository permissions → Issues: Read-only or Read and write
- Repository permissions → Pull requests: Read-only
- Repository permissions → Metadata: Read-only, which is required and usually enabled by default
Under Subscribe to events, select events you want to receive:
- Issues
- Pull request
GitHub will also send a ping event when you create or update the webhook configuration.
Save the app. If your Express server and taupi tunnel are running, you should immediately see a ping delivery in your terminal.
6. Install the GitHub App on a test repository
A GitHub App does not receive repository events until it is installed somewhere.
From the GitHub App settings page:
- Click Install App in the left sidebar.
- Choose your account or organization.
- Select a test repository.
- Complete installation.
Your app should receive an installation event. Then trigger repository events:
- Open a new issue to trigger
issues.opened - Edit the issue title to trigger
issues.edited - Open a pull request to trigger
pull_request.opened - Add commits to the pull request branch to trigger
pull_request.synchronize
Your local terminal should show logs like:
--- GitHub webhook received ---
event: issues
delivery: 3c9246d0-....
action: opened
issue opened: your-user/your-repo#1
title: Test local webhook
author: your-user
7. Inspect deliveries in the GitHub UI
When debugging webhooks, the GitHub UI is extremely useful. For a GitHub App webhook:
- Go to Settings → Developer settings → GitHub Apps.
- Click Edit next to your app.
- Open Advanced in the sidebar.
- Find Recent Deliveries.
- Click a delivery.
You can inspect:
- Request headers
- Request payload
- Response status
- Response body
- Timing information
- Error messages if GitHub could not connect
This is the fastest way to answer questions like:
- Did GitHub send the webhook at all?
- Did it go to the taupi URL I expected?
- Did my app return
202,401, or500? - Was the payload event actually
issues,pull_request, or something else? - Did signature verification fail?
For local development, keep the GitHub delivery page open next to your terminal logs.
8. Redeliver a webhook after changing code
Redelivery is one of the best tools for webhook development. Instead of creating a new issue or pull request every time you change code, you can replay the same delivery.
A typical loop looks like this:
- Trigger an event, such as opening an issue.
- Your Express handler crashes or logs the wrong data.
- Inspect the payload in GitHub's Recent Deliveries.
- Fix your local code.
- Restart your Node process if needed.
- Click Redeliver in the GitHub delivery view.
- Watch the request hit your local Express app again through taupi.
To redeliver:
- Open your GitHub App settings.
- Go to Advanced → Recent Deliveries.
- Click the failed or interesting delivery.
- Click Redeliver.
- Confirm the redelivery.
One development caveat: if you implemented duplicate detection using X-GitHub-Delivery, redelivery may be treated as the same logical delivery. In the sample app above, the in-memory seenDeliveries set can cause a redelivered event to be ignored after it has already succeeded. For debugging, you can temporarily comment out the duplicate check, restart the server, or clear the set when you want to replay a delivery.
In production, do not simply remove idempotency. Webhooks can be delivered more than once, and retries are normal. Instead, make handlers safe to run repeatedly: store processed delivery IDs, use database constraints, and design side effects carefully.
9. Troubleshooting common failures
GitHub shows a connection error
Check that all three pieces are running:
node index.js
taupi tunnel 3000
Also verify your webhook URL includes the route path:
https://your-url.taupi.dev/github/webhooks
A common mistake is setting the webhook URL to only the tunnel root.
GitHub receives 404
Your server is reachable, but the route does not match. Confirm the route in Express:
app.post('/github/webhooks', ...);
Then confirm the GitHub App webhook URL ends with /github/webhooks.
GitHub receives 401
A 401 usually means signature verification failed. Check:
- The GitHub App webhook secret exactly matches
.env - You restarted Node after editing
.env - You are using
express.raw()for the webhook route - You did not register
express.json()before the webhook route - GitHub is sending
content-type: application/json
If you change the webhook secret in GitHub, also change it locally.
GitHub receives 500
Your signature verification passed, but your handler threw an error. Check your terminal stack trace. You can redeliver the same webhook after fixing the bug.
No events after installation
Make sure the app is installed on the repository that you are testing. Also make sure your app is subscribed to the event. Permissions and event subscriptions are separate settings: an app may have permission to read issues but still not subscribe to the issues webhook event.
Tunnel URL changed
If you are using a short-lived development tunnel, the public URL can change when you restart it. Update the GitHub App webhook URL and save the app again. If you test GitHub Apps frequently, a persistent custom subdomain avoids this repetitive step.
10. Practical webhook handler tips
Return quickly
GitHub expects a timely response. Do not perform long-running work directly before responding. For real apps, validate the signature, parse the payload, enqueue a job, and return 202.
Log delivery IDs
Always log X-GitHub-Delivery. It lets you correlate your local logs with GitHub's delivery UI:
console.log({
delivery: req.get('x-github-delivery'),
event: req.get('x-github-event'),
action: req.payload.action,
});
Handle unknown events gracefully
GitHub can add event actions over time, and your app settings may change. Do not crash on an event you do not handle. Log it and return a 2xx if the webhook was valid.
Keep secrets out of logs
Never log your webhook secret or computed HMAC values. If you need to debug verification, log high-level information such as whether the header was present and whether the body parser was raw.
Use a real test repository
Synthetic payloads are helpful for unit tests, but GitHub's real payloads contain many nested fields and subtle differences between actions. Use a disposable repository and the redelivery UI to build confidence.
Complete workflow recap
Here is the full local development workflow in command form:
# 1. Start the Express app
node index.js
# 2. In another terminal, expose it
taupi tunnel 3000
# 3. Use this in your GitHub App webhook settings
# https://your-url.taupi.dev/github/webhooks
# 4. Trigger events in a test repo
# - create issue
# - open pull request
# - install/uninstall app
# 5. Debug with GitHub App settings → Advanced → Recent Deliveries
# 6. Click Redeliver after code changes
Conclusion
Testing GitHub App webhooks locally is mostly about getting four details right: expose your local server through a public HTTPS URL, configure the GitHub App with that exact URL, verify signatures against the raw request body, and use the GitHub delivery UI to inspect and replay requests.
The Express implementation in this guide gives you a secure foundation for local testing because it validates X-Hub-Signature-256 before trusting the payload. taupi fills the networking gap by forwarding GitHub's HTTPS webhook requests to your local localhost:3000 server, so you can debug with normal terminal logs and fast code changes.
Once this loop is working, webhook development becomes much less mysterious: trigger an event, inspect the delivery, fix the handler, redeliver, and repeat.