← All guides

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.

github-appswebhooksnodejsexpresslocalhosttesting

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, and installation
  • 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 example issues or pull_request
  • X-GitHub-Delivery: a unique delivery ID for tracing and idempotency
  • X-Hub-Signature-256: an HMAC signature generated from the raw request body and your webhook secret
  • User-Agent: usually something like GitHub-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:

  1. Start your Express app on localhost:3000.
  2. Start a taupi tunnel to expose port 3000 publicly.
  3. Put the public taupi HTTPS URL into your GitHub App webhook settings.
  4. Trigger an event in a test repository.
  5. Inspect logs locally and inspect the delivery in GitHub.
  6. 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:

  1. Open Settings from your profile menu.
  2. Go to Developer settings.
  3. Select GitHub Apps.
  4. 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:3000 is fine for development
  • Webhook: enabled
  • Webhook URL: your taupi URL plus /github/webhooks
  • Webhook secret: exactly the same value as GITHUB_WEBHOOK_SECRET in .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:

  1. Click Install App in the left sidebar.
  2. Choose your account or organization.
  3. Select a test repository.
  4. 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:

  1. Go to SettingsDeveloper settingsGitHub Apps.
  2. Click Edit next to your app.
  3. Open Advanced in the sidebar.
  4. Find Recent Deliveries.
  5. 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, or 500?
  • 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:

  1. Trigger an event, such as opening an issue.
  2. Your Express handler crashes or logs the wrong data.
  3. Inspect the payload in GitHub's Recent Deliveries.
  4. Fix your local code.
  5. Restart your Node process if needed.
  6. Click Redeliver in the GitHub delivery view.
  7. Watch the request hit your local Express app again through taupi.

To redeliver:

  1. Open your GitHub App settings.
  2. Go to AdvancedRecent Deliveries.
  3. Click the failed or interesting delivery.
  4. Click Redeliver.
  5. 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.