Build a GitHub Webhook Monitor
Receive and verify real-time GitHub push events with TypeScript and Express.
Introduction
30 Second Summary
Think about how you get notified instantly when someone messages you, instead of refreshing your inbox every five minutes. That instant notification pattern is exactly how modern software systems talk to each other.
In this project, you will build a TypeScript server that receives real-time push events from GitHub, verifies they are authentic using cryptographic signatures, and displays a formatted activity feed in your terminal.
What You'll Build
Every time you push code to a connected GitHub repository, your server receives the event within seconds, proves it genuinely came from GitHub using HMAC-SHA256 signatures, and prints a clean summary of who pushed, what branch changed, and where to see the diff.
By the end of this project, you'll have:
- A live terminal feed that displays push events the moment you push code to GitHub, showing the branch, author, and commit messages.
- Cryptographic signature verification that rejects forged requests with a 401, proving your server only processes authentic GitHub events.
- Full TypeScript autocomplete on nested GitHub payload fields like commits[0].author.username, powered by typed interfaces you define yourself.
- Secret Mission: extend your server to handle multiple event types (issues, stars) using TypeScript discriminated unions.
Are there any prerequisites?
You should have Node.js installed, a GitHub account, and VS Code (or another code editor) ready to go. Basic familiarity with TypeScript helps, but you do not need to be an expert.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the TypeScript Project
To receive real-time events from GitHub, you need a server that can accept incoming HTTP requests. That server needs to be written in TypeScript so you get type safety on the complex webhook payloads GitHub sends.
In this step, you will scaffold a TypeScript project with Express and hot-reloading so every change you make takes effect instantly without restarting the server.
In this step, get ready to:
- Create a project folder and install all dependencies.
- Configure TypeScript and add development scripts.
- Create a minimal Express server with a health-check route.
Initialize the project and install dependencies
Every Node.js project starts with a folder, a package.json, and its dependencies installed.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal (macOS) or Command Prompt (Windows) and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir github-webhook-monitor
cd github-webhook-monitor
- Initialize a new npm project by running:
npm init -y
You will see npm print out the generated package.json contents in your terminal.
- Install Express as a production dependency by running:
npm install express
- Install the TypeScript development tools by running:
npm install --save-dev typescript ts-node-dev @types/node @types/express
What are these packages?
- typescript is the TypeScript compiler that checks your types.
- ts-node-dev runs your TypeScript files directly and restarts the server whenever you save a change.
- @types/node and @types/express provide TypeScript type definitions for Node.js and Express.
- Create the src folder where your TypeScript source files will live by running:
mkdir src
- Confirm everything was created by running:
ls
You should see node_modules, package.json, package-lock.json, and src in the output.
Seeing 'command not found' for npm?
This means Node.js is not installed or not in your PATH. Confirm Node.js is installed by running node --version.
If nothing comes back, install Node.js from nodejs.org and restart your terminal.
help me fix npm command not found on my machine
Configure TypeScript
TypeScript needs a configuration file that tells the compiler how to process your code. You also need development scripts in package.json so you can start your server with a single command.
- Open the github-webhook-monitor folder in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Visual Studio Code, and pressing Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the github-webhook-monitor folder, and click Open.
- Create a new file called tsconfig.json in the project root by clicking the new file icon in the Explorer sidebar.
- Paste the following configuration into tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
What does this configuration do?
- target tells TypeScript to compile to ES2020 JavaScript, which Node.js supports natively.
- rootDir and outDir separate your source TypeScript files from the compiled JavaScript output.
- strict: true enables full type checking so the compiler catches mistakes before runtime.
- esModuleInterop: true lets you use import express from "express" syntax with CommonJS packages.
- Save tsconfig.json with Cmd+S (macOS) or Ctrl+S (Windows).
Now you need to add scripts to package.json so you can run your server during development.
- Open package.json in the Explorer sidebar.
- Find the "scripts" section. It currently looks like this:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
- Replace the entire "scripts" block (including the curly braces) with these three scripts:
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
What do these scripts do?
- dev runs your TypeScript server with hot-reloading. Every time you save a file, the server restarts automatically.
- build compiles your TypeScript to JavaScript for production.
- start runs the compiled JavaScript output.
- You can also remove the "main", "keywords", "author", and "license" fields that npm init generated. They are not needed for this project.
- Save package.json.
Not sure which lines to remove?
If you are unsure about the edits, use the double-check tab at the end of this step to compare your file against the expected result.
help me clean up my package.json for this TypeScript project
Create the entry point and start the server
Your server needs an entry point file that Express uses to listen for incoming requests.
- In the Explorer sidebar, expand the src folder.
- Create a new file inside src called server.ts by right-clicking the src folder and selecting New File.
- Paste the following code into src/server.ts:
import express, { Request, Response } from "express";
// Create the Express application
const app = express();
const PORT = 3000;
// Health check route to confirm the server is running
app.get("/", (_req: Request, res: Response) => {
res.send("Webhook server is running! POST to /webhook to receive events.");
});
// Start listening for incoming requests
app.listen(PORT, () => {
console.log(`Webhook server listening on http://localhost:${PORT}`);
});
What does this code do?
- import express, { Request, Response } imports Express and its TypeScript types for request and response objects.
- app.get("/", ...) creates a route that responds when someone visits the server's root URL in a browser.
- app.listen(PORT, ...) starts the server on port 3000 and prints a confirmation message.
- Save src/server.ts.
Before you run this, what do you expect to see in your terminal when the server starts?
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both macOS and Windows).
- Start your development server by running:
npm run dev
You should see the message Webhook server listening on http://localhost:3000 printed in your terminal.
- Open your browser and visit http://localhost:3000.
You should see the text "Webhook server is running! POST to /webhook to receive events." displayed in the browser.
Server not starting?
- If you see a "Cannot find module" error, confirm your file is saved at src/server.ts (inside the src folder, not the project root).
- If you see a port conflict error, another process is using port 3000. Close it or change PORT in server.ts to 3001.
- If the browser shows nothing, make sure you typed http://localhost:3000 (not https).
my TypeScript server won't start with npm run dev
Hot-reloading is active
Leave this terminal running. Every time you save a file, ts-node-dev automatically restarts the server. You will see [INFO] messages in the terminal each time it restarts.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: tsconfig.json, package.json, and src/server.ts.
ⓧ I'd like to double check the full code
Here are the three files you should have at this point. Compare yours against these.
package.json:
{
"name": "github-webhook-monitor",
"version": "1.0.0",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^5.2.1"
},
"devDependencies": {
"typescript": "^5.9.3",
"ts-node-dev": "^2.0.0",
"@types/node": "^22.15.0",
"@types/express": "^5.0.6"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
src/server.ts:
import express, { Request, Response } from "express";
const app = express();
const PORT = 3000;
app.get("/", (_req: Request, res: Response) => {
res.send("Webhook server is running! POST to /webhook to receive events.");
});
app.listen(PORT, () => {
console.log(`Webhook server listening on http://localhost:${PORT}`);
});
Your TypeScript project is scaffolded and your server is running with hot-reloading. Next up, you will build the webhook endpoint that receives incoming POST requests from GitHub.
Build the Webhook Endpoint
Your server can respond to browser visits, but it cannot receive incoming events yet. Webhooks work by sending an HTTP POST request to your server whenever something happens.
In this step, you will add a POST endpoint that receives JSON payloads, logs them with a timestamp, and immediately responds with 200 OK. That instant response is the golden rule of webhooks. If your server takes too long to reply, the sender (GitHub in our case) considers the delivery a failure.
In this step, get ready to:
- Add a POST /webhook route that logs incoming payloads and responds instantly.
- Handle different GitHub event types including the special ping event.
- Test your endpoint locally with a curl command.
Add the POST /webhook route
Before your server can receive JSON payloads, it needs middleware that parses incoming request bodies. Then it needs a dedicated route that handles POST requests.
- In VS Code, open src/server.ts.
- Find the line that says const PORT = 3000; and add the following code below it:
app.use(express.json());
app.post("/webhook", (req: Request, res: Response) => {
const event = req.headers["x-github-event"] as string;
// Log the timestamp and event type
console.log(`[${new Date().toISOString()}] Received event: ${event}`);
// Pretty-print the full payload
console.log(JSON.stringify(req.body, null, 2));
// Respond immediately - the golden rule of webhooks
res.status(200).send("OK");
});
What does this code do?
app.use(express.json()) is middleware that automatically parses JSON request bodies. Without it, req.body would be undefined.
The route reads the x-github-event header to identify what type of event arrived (push, issues, ping, etc.). It logs the full payload with JSON.stringify so you can inspect the data GitHub sends.
The 200 response goes out immediately before any processing. GitHub terminates the connection if your server does not respond within 10 seconds.
- Save src/server.ts.
- Check the terminal where npm run dev is running. You should see it restart automatically and print the startup message again.
Server not restarting?
Make sure npm run dev is still running in your terminal. If it crashed, restart it. Also check for red syntax errors in the terminal output. A missing bracket or quote will prevent compilation.
Help me debug my server restart issue
Handle the ping event and log event types
When you first connect a webhook to GitHub, it sends a special ping event to confirm the connection works. Your server should recognise this event and log it differently from regular events.
- In src/server.ts, find the closing }); of your /webhook route (the line right after res.status(200).send("OK");).
- Replace that closing }); with the following code so the handler continues with event-specific logic:
// Handle the special ping event GitHub sends on first connection
if (event === "ping") {
console.log("[PING] Webhook connected successfully!");
return;
}
// Log any other event type
console.log(`[EVENT] Received event: ${event}`);
});
What does this code do?
The ping check catches GitHub's initial handshake event and logs a friendly confirmation. The return prevents the generic event log from firing for pings.
For all other events, the handler logs the event type so you can see exactly what GitHub is sending. Later, you will add specific handling for push events.
- While you are in the file, scroll down to the app.listen block and add a second log line so it looks like this:
app.listen(PORT, () => {
console.log(`Webhook server listening on http://localhost:${PORT}`);
console.log("Waiting for GitHub events...\n");
});
- Save src/server.ts.
- Check your terminal. You should see both startup messages printed: the server URL and "Waiting for GitHub events..." on the next line.
Seeing a TypeScript error?
If you see an error about }); or unexpected tokens, make sure your route handler has exactly one closing }); at the end. It is easy to end up with a duplicate when adding the new code.
Help me fix my TypeScript syntax error in the webhook route
Test with curl
Your endpoint is ready to receive POST requests. You can simulate a webhook delivery from your own machine using curl.
- Open a second terminal (keep the dev server running in the first one).
- Before you run the next command, predict what you will see in the server terminal. Will the event type show as push, undefined, or something else?
- Send a test payload to your webhook by running:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d "{\"test\": true}"
- Switch to the terminal running your server.
You should see the event logged as undefined (because your curl request did not include an x-github-event header), followed by the JSON body printed below it. The curl output itself shows OK confirming the 200 response.
Why is the event undefined?
Real GitHub webhook deliveries include the X-GitHub-Event header automatically. Your manual curl request did not set that header, so it comes through as undefined. In the next step, you will connect to real GitHub events and see actual event names appear.
Not seeing any output in the server terminal?
Make sure you are sending the request to port 3000 and that npm run dev is still running. If curl returns "connection refused", the server is not running.
Help me troubleshoot why my curl request is not reaching my webhook server
✔️ Awesome, I've got everything!
Great. Double check you have saved src/server.ts and that both startup messages appear in your terminal.
ⓧ I'd like to double check the full code
Here is the full src/server.ts at this point:
import express, { Request, Response } from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.post("/webhook", (req: Request, res: Response) => {
const event = req.headers["x-github-event"] as string;
console.log(`[${new Date().toISOString()}] Received event: ${event}`);
console.log(JSON.stringify(req.body, null, 2));
res.status(200).send("OK");
if (event === "ping") {
console.log("[PING] Webhook connected successfully!");
return;
}
console.log(`[EVENT] Received event: ${event}`);
});
app.get("/", (_req: Request, res: Response) => {
res.send("Webhook server is running! POST to /webhook to receive events.");
});
app.listen(PORT, () => {
console.log(`Webhook server listening on http://localhost:${PORT}`);
console.log("Waiting for GitHub events...\n");
});
Your server now receives POST requests, logs them, and responds instantly. Next up, you will expose this endpoint to the public internet and connect it to a real GitHub repository so actual push events start flowing in.
Go Live with ngrok and GitHub
Your webhook server can receive HTTP requests on localhost:3000, but GitHub has no way to reach your local machine. External services can only deliver webhooks to public URLs.
In this step, you will use ngrok to create a public HTTPS tunnel that forwards traffic from the internet to your local server. Then you will configure a real GitHub webhook to send push events through that tunnel.
In this step, get ready to:
- Install ngrok and expose your local server to the internet.
- Configure a GitHub webhook pointed at your ngrok URL.
- Push a commit and see the event arrive in your terminal in real time.
Install ngrok and expose your server
ngrok creates a secure tunnel from a public URL to your local machine. GitHub will send webhook payloads to the public URL, and ngrok will forward them straight to your Express server on port 3000.
- Open https://dashboard.ngrok.com/signup in your browser and sign up for a free ngrok account.
- After signing up, copy your authtoken from the Your Authtoken section of the ngrok dashboard.
Where is my authtoken?
After signing up, the ngrok dashboard shows your authtoken at https://dashboard.ngrok.com/get-started/your-authtoken. Click the copy button next to the token value.
- Install ngrok on your machine by running the command for your operating system:
macOS
brew install --cask ngrok
Homebrew not installed?
If you see a "command not found" error for brew, install Homebrew first by visiting https://brew.sh and following the one-line install command.
help me install Homebrew on macOS
Windows
winget install ngrok -s msstore
Winget not available?
If winget is not recognized, you can download ngrok directly from https://ngrok.com/download. Extract the zip and add the folder to your system PATH.
help me install ngrok on Windows without winget
- Open a new terminal window (keep your npm run dev server running in the original one).
- Authenticate ngrok with your account by running this command, replacing the placeholder with your copied authtoken:
ngrok config add-authtoken your-authtoken-here
What does this command do?
This saves your authtoken to ngrok's local configuration file so it can connect to your ngrok account on every subsequent run. You only need to do this once per machine.
- Start the ngrok tunnel by running:
ngrok http 3000
ngrok displays a status panel in your terminal. Look for the Forwarding line. It shows a public HTTPS URL like https://abc123.ngrok-free.app pointing to your local port 3000.
- Copy the full HTTPS forwarding URL. You will need it in the next substep.
Why does ngrok take over my terminal?
ngrok runs as a foreground process that displays live traffic stats. You now have two terminal windows open: one running your Express server (npm run dev) and one running ngrok. Both must stay open for webhooks to work.
Seeing an error when starting ngrok?
Make sure you authenticated first with ngrok config add-authtoken. Without authentication, ngrok cannot create a tunnel.
If you see a port-in-use error, confirm your Express server is actually running on port 3000 in the other terminal.
help me troubleshoot ngrok not starting
Configure a GitHub webhook
Now you will tell GitHub to send push events to your ngrok URL. You need a repository to connect the webhook to.
- Go to any repository you own on GitHub (or create a new test repository).
- Click the Settings tab at the top of the repository page.
- Click Webhooks in the left sidebar.
- Click Add webhook.
- In the Payload URL field, paste your ngrok HTTPS URL followed by /webhook (for example, https://abc123.ngrok-free.app/webhook).
- Set Content type to application/json.
- In the Secret field, type my-webhook-secret-123.
- Under Which events would you like to trigger this webhook?, select Just the push event.
- Click Add webhook.
GitHub immediately sends a ping event to verify the connection. Switch to the terminal running your Express server.
You should see output showing the ping event received, including [PING] Webhook connected successfully! logged in your terminal.
What is the ping event?
When you first add a webhook, GitHub sends a ping event to confirm the URL is reachable and responding with a 200 status. This is not a push event. It is GitHub's handshake saying "I can reach your server."
Don't see the ping event?
Check that your Express server terminal still shows "Webhook server listening on http://localhost:3000". If the server crashed, restart it with npm run dev.
Confirm your ngrok terminal is still running and showing the forwarding URL. If ngrok stopped, restart it with ngrok http 3000 and update the webhook URL in GitHub settings since the URL changes each time ngrok restarts on a free plan.
help me debug why GitHub's ping event isn't reaching my server
Trigger a real push event
The ping confirmed your connection works. Now you will trigger an actual push event by committing and pushing a change to the repository.
- Open any file in your connected GitHub repository and make a small change (add a comment, edit the README, anything).
- Commit and push the change to the repository.
Before you check your terminal, what do you think you will see? Will the push event appear instantly, or will there be a delay?
Switch to the terminal running your Express server. Within seconds you should see the full push event payload logged, including the repository name, the branch ref, the commit messages, and the pusher information.
What should I see?
Your terminal displays the timestamp, the event type (push), and the full JSON payload from GitHub. The payload includes fields like ref (the branch), commits (an array of commit objects), and pusher (who pushed).
This is your server receiving a real-time event from GitHub. The data arrives within seconds of the push.
Your server is wide open
Right now your server accepts ANY POST request to /webhook, even fake ones. Anyone who discovers your ngrok URL can send fabricated events and your server will happily process them.
You set a secret (my-webhook-secret-123) when creating the webhook, but your server does not verify it yet. That is a security problem you will fix in the next step.
Your server is now receiving real events from GitHub in real time. Next up, you will add HMAC-SHA256 signature verification so your server can cryptographically prove each request is genuinely from GitHub before processing it.
Verify Signatures and Format Output
Your webhook endpoint is live on the internet through ngrok. That means anyone who discovers the URL can send fake events to it.
GitHub solves this by signing every payload with HMAC-SHA256 using the secret you set during webhook configuration. In this step, you will verify those signatures cryptographically so your server only processes authentic requests. Then you will format the output into a readable activity feed.
In this step, get ready to:
- Capture the raw request body for cryptographic verification.
- Implement HMAC-SHA256 signature verification that rejects forged requests.
- Add a TypeScript interface for the push payload and format a readable activity log.
Capture the raw body for signature verification
Signature verification must run on the exact bytes GitHub sent. If you verify after Express parses the JSON, whitespace or key-order differences cause mismatches.
- In src/server.ts, find the top section of your file (the import, constants, and middleware lines).
- Replace everything from the import line down to and including app.use(express.json()); with the following code:
import express, { Request, Response } from "express";
import crypto from "crypto";
const app = express();
const PORT = 3000;
// The secret you set when configuring the GitHub webhook
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "my-webhook-secret-123";
// Store the raw bytes so signature verification works on the original payload
app.use(
express.json({
verify: (req: Request, _res, buf) => {
(req as any).rawBody = buf;
},
})
);
What does this code do?
The verify callback inside express.json() runs before the body is parsed. It receives the raw Buffer (buf) and stashes it on the request object as rawBody.
Later, you will compute the HMAC hash on these exact bytes. If you used JSON.stringify(req.body) instead, subtle re-serialization differences would make the hash not match GitHub's.
- Save src/server.ts.
- Check your terminal where npm run dev is running. The server should restart and print Webhook server listening on http://localhost:3000 again with no errors.
Seeing a TypeScript error on save?
Make sure you kept the import crypto from "crypto"; line. The crypto module is built into Node.js so no install is needed, but TypeScript still needs the import statement.
Also confirm esModuleInterop is set to true in your tsconfig.json. Without it, the default import for crypto will not resolve.
Help me fix a TypeScript error after adding the crypto import and verify callback.
Implement the verifySignature function
GitHub sends a header called X-Hub-Signature-256 with every webhook delivery. It contains the HMAC-SHA256 hash of the payload body, computed with your shared secret. Your server computes the same hash locally and compares the two.
- In src/server.ts, add the following function directly below the app.use(express.json(...)) middleware block:
function verifySignature(req: Request): boolean {
// Read the signature GitHub sent with this request
const signature = req.headers["x-hub-signature-256"] as string | undefined;
if (!signature) {
return false;
}
// Compute what the signature SHOULD be using our shared secret
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
const digest = "sha256=" + hmac.update((req as any).rawBody).digest("hex");
// Use constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
} catch {
return false;
}
}
What does this code do?
- It reads the x-hub-signature-256 header from the incoming request. If missing, the request is immediately invalid.
- It computes an HMAC-SHA256 hash of the raw body using your WEBHOOK_SECRET, then prepends sha256= to match GitHub's format.
- It uses crypto.timingSafeEqual for the comparison. A plain === operator leaks timing information that attackers can exploit to guess a valid signature byte by byte.
The try/catch handles the case where the two buffers have different lengths, which would throw a RangeError.
Now you need to call this function in your route handler so unsigned requests get rejected.
- In the same file, find the beginning of your app.post("/webhook", ...) handler. It currently looks like this:
app.post("/webhook", (req: Request, res: Response) => {
const event = req.headers["x-github-event"] as string;
console.log(`[${new Date().toISOString()}] Received event: ${event}`);
console.log(JSON.stringify(req.body, null, 2));
res.status(200).send("OK");
- Delete the two console.log lines (the timestamp log and the JSON.stringify dump).
- Add the signature verification check between the event variable and the res.status(200) line, so the beginning of your handler now looks like this:
app.post("/webhook", (req: Request, res: Response) => {
const event = req.headers["x-github-event"] as string;
// Reject any request that doesn't have a valid GitHub signature
if (!verifySignature(req)) {
console.log(`[REJECTED] Invalid signature - request not from GitHub`);
res.status(401).send("Invalid signature");
return;
}
res.status(200).send("OK");
- Save src/server.ts.
Before you test this, what do you think will happen if you send a request without a valid signature header? What HTTP status code should come back?
- Open a new terminal and test with a fake request by running:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d "{\"fake\": true}"
You should see [REJECTED] Invalid signature - request not from GitHub in your server terminal, and curl returns Invalid signature with a 401 status.
Still getting a 200 OK instead of 401?
Make sure you placed the if (!verifySignature(req)) check BEFORE the res.status(200).send("OK") line. The signature check must be the first thing the handler does after reading the event header.
Also confirm your file saved correctly. Check the terminal for the ts-node-dev restart message.
Help me figure out why my webhook still returns 200 instead of rejecting unsigned requests.
Add a TypeScript interface and format the output
Your server now rejects fake requests. The raw JSON dump from earlier is gone, so you need structured formatting for legitimate push events. A TypeScript interface gives you autocomplete on every nested field in the payload.
- In src/server.ts, add the following interface directly above the app.post("/webhook", ...) handler:
interface PushEvent {
ref: string;
before: string;
after: string;
forced: boolean;
compare: string;
pusher: { name: string; email: string };
commits: Array<{
id: string;
message: string;
timestamp: string;
author: { name: string; username: string };
}>;
repository: { full_name: string };
}
What does this interface do?
The PushEvent interface maps the shape of GitHub's push event payload. Once you cast req.body as PushEvent, TypeScript gives you autocomplete on every field. You catch typos like commit.autor at compile time instead of discovering them in production.
Now replace the push event handling inside your route handler. The output should show branch, author, and individual commits.
- Inside your app.post("/webhook", ...) handler, find the section after the ping check. It currently looks like this:
console.log(`[EVENT] Received event: ${event}`);
- Replace that line with the following push handler and fallback log:
if (event === "push") {
const payload = req.body as PushEvent;
const branch = payload.ref.replace("refs/heads/", "");
console.log("\n========== PUSH EVENT ==========");
console.log(`Repository: ${payload.repository.full_name}`);
console.log(`Branch: ${branch}`);
console.log(`Pusher: ${payload.pusher.name}`);
console.log(`Forced: ${payload.forced ? "Yes" : "No"}`);
console.log(`Compare: ${payload.compare}`);
console.log(`\nCommits (${payload.commits.length}):`);
payload.commits.forEach((commit, index) => {
const shortId = commit.id.substring(0, 7);
const message = commit.message.split("\n")[0];
console.log(` ${index + 1}. [${shortId}] ${message} (${commit.author.name})`);
});
console.log("================================\n");
return;
}
console.log(`[EVENT] Received unhandled event: ${event}`);
What does this code do?
- It casts req.body to PushEvent so TypeScript knows the shape of every field.
- It strips refs/heads/ from payload.ref to show just the branch name (e.g. main instead of refs/heads/main).
- It prints each commit with a short 7-character hash, the first line of the message, and the author name.
- The fallback console.log catches any event type your server does not handle yet.
- Save src/server.ts.
Before you push, predict what you expect to see in the terminal. Will the push event be accepted or rejected? What details will appear?
- In your test repository (the one with the GitHub webhook), make any small change to a file, commit, and push.
Within seconds, your server terminal should display a formatted activity log like this:
========== PUSH EVENT ==========
Repository: your-username/your-repo
Branch: main
Pusher: your-username
Forced: No
Compare: https://github.com/your-username/your-repo/compare/abc123...def456
Commits (1):
1. [a1b2c3d] your commit message (Your Name)
================================
What should I see?
The exact values will match your GitHub username, repository name, branch, and commit message. The important thing is the structured format with labelled fields.
If you pushed to a branch other than main, the Branch line will reflect that. If your push had multiple commits, each one appears numbered in the list.
Not seeing the formatted output?
Check that your ngrok tunnel is still running. Free ngrok sessions stay active indefinitely but the URL changes if you restart ngrok. If you restarted it, update your GitHub webhook payload URL to the new ngrok HTTPS URL.
Also verify the server shows [PING] Webhook connected successfully! after you update the URL. GitHub sends a ping event when you save webhook settings.
Help me figure out why my push events are not appearing in my terminal.
Now confirm that fake requests are still rejected over the public URL too.
- Run this curl command using your ngrok URL to simulate an attacker:
curl -X POST [[NGROK_URL="your ngrok HTTPS URL"]]/webhook -H "Content-Type: application/json" -d "{\"fake\": true}"
You should see [REJECTED] Invalid signature - request not from GitHub in your server terminal. The curl response shows Invalid signature with a 401 status. Your server now only processes events that are cryptographically signed by GitHub.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/server.ts and confirm the server restarted without errors.
ⓧ I'd like to double check the full code
Your src/server.ts is longer than 30 lines now, so scroll up and confirm each section is present in this order:
- The import block (express and crypto), constants (PORT, WEBHOOK_SECRET), and the express.json() middleware with the verify callback.
- The verifySignature function that reads x-hub-signature-256, computes the HMAC digest, and uses crypto.timingSafeEqual.
- The PushEvent interface with fields for ref, pusher, commits, repository, compare, and forced.
- The app.post("/webhook", ...) handler that calls verifySignature first, responds 200, handles ping, formats push events, and logs unhandled events.
- The app.get("/", ...) route and app.listen at the bottom.
Your webhook server is now production-grade. It rejects forged requests and presents real events in a clean, readable format. In the Secret Mission, you will extend this to handle multiple event types using TypeScript discriminated unions.
Secret mission
Handle Multiple Event Types with Discriminated Unions
Your webhook server handles push events, but real-world servers process dozens of event types. Extend your server to handle issues and stars using TypeScript interfaces and type-safe event routing. You'll update your GitHub webhook subscription and see different formatted outputs for each event type in your terminal.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free ngrok tunnel, so there are no ongoing costs.
Resources you used:
- The github-webhook-monitor project folder on your Desktop.
- The running ngrok tunnel (terminal process).
- The running ts-node-dev server (terminal process).
- The GitHub webhook configured on your test repository.
- ngrok installed via winget.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with webhook events.
- Your ts-node-dev server will keep running and receiving events until you close the terminal.
- Your ngrok tunnel stays active as long as the ngrok process is running.
- Your GitHub webhook will continue sending events to the ngrok URL.
- ngrok remains installed on your machine for other tunneling projects.
Pause - I'll come back to this later
Shut down running processes to free up terminal sessions and port 3000. Your code and webhook config stay intact for when you return.
- In the terminal running your ts-node-dev server, press Ctrl+C to stop it.
- In the terminal running ngrok, press Ctrl+C to stop the tunnel.
- Your GitHub webhook will show failed deliveries while the tunnel is down. When you restart ngrok you will get a new URL, so you will need to update the webhook payload URL in your repository settings.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- In the terminal running your ts-node-dev server, press Ctrl+C to stop it.
- In the terminal running ngrok, press Ctrl+C to stop the tunnel.
- Delete the GitHub webhook. Go to your test repository on GitHub, click Settings, click Webhooks in the left sidebar, find your webhook, and click Delete.
- Delete the project folder on macOS/Linux by running:
cd ~/Desktop
rm -rf github-webhook-monitor
- Delete the project folder on Windows by running:
cd ~\Desktop
Remove-Item -Recurse -Force github-webhook-monitor
- Uninstall ngrok by running:
winget uninstall ngrok
Nice Work!
Nice Work!
You just built a real-time webhook monitor that receives, authenticates, and processes live GitHub events. Your terminal is now a live activity feed for your repository.
Here's what you learned:
- Built a TypeScript Express server that receives real-time webhook events from GitHub and prints a formatted activity feed in your terminal.
- Implemented HMAC-SHA256 signature verification using crypto.timingSafeEqual to cryptographically reject forged requests.
- Typed the entire GitHub push payload with TypeScript interfaces for compile-time safety and full autocomplete on nested fields.
- Secret Mission: Extended the server to handle multiple event types (issues, stars) with type-safe discriminated unions.
Ready to quiz yourself?