Recently, I became curious about how GitHub webhooks work. To deepen my understanding, I built a small Node.js project that sends an email notification every time a pull request (PR) is opened.
Yes, GitHub already has a built-in notification system, but building your own gives you full control and a deeper understanding of the ecosystem.
π First, Whatβs HMAC?
Before we dive into code, itβs important to understand how GitHub ensures that webhook events are secure.
HMAC stands for Hash-based Message Authentication Code. Itβs a cryptographic technique used to verify both:
- The integrity of a message (that it hasnβt been tampered with)
- The authenticity of the sender (that itβs really from GitHub)
GitHub does this by hashing the body of the request with a shared secret you provide when creating the webhook. It then sends that signature along with the request using the X-Hub-Signature-256
header.
π§© Webhook Levels
Webhooks can be configured at three levels:
- Repository level β scoped to a single repo
- Organization level β applies to all repositories within an organization
- GitHub App level β used in GitHub Apps for deep integration across multiple repositories or organizations
For this example, I used an organization-level webhook so it applies to all repos in my org.
π Signature Verification
To verify that the incoming request is really from GitHub, we need to:
- Capture the raw request body before itβs parsed by Express.
- Recompute the HMAC signature using the same secret.
- Use a constant-time comparison to prevent timing attacks.
Hereβs how I do it:
πΈ Express Middleware
We add a raw body parser to capture the exact payload:
app.use(express.json({
verify: (req, _, buf) => {
req.rawBody = buf;
}
}));
This step is critical β HMAC must be calculated over the raw payload. If you parse it first, youβll get a mismatch.
πΈ Signature Verifier (utils/verifier.js
)
import crypto from 'crypto';
import { config } from './config.js';
export const verifySignature = (req)=>{
const signature = req.headers['x-hub-signature-256'];
if(!signature){
return false;
}
const hmac = crypto.createHmac("sha-256", config.GIT_WEBHOOK_SECRET );
hmac.update(req.rawBody);
const expectedSignature = `sha256=${hmac.digest('hex')}`;
return signature === expectedSignature;
}
π¬ Sending Email Notifications
Once the signature is verified, I check if the action is "opened"
on a PR, and then send an email.
I used Nodemailer with Gmail as the SMTP service. Since my Gmail account uses 2FA, I generated an App Password to authenticate.Use the app password without spaces.
πΈ Mailer (services/emailService.js
)
const transporter = nodemailer.createTransport({
service:"Gmail",
auth:{
user: config.EMAIL,
pass: config.PASSWORD
}
})
export const sendPRrequestMail = (recipents, subject, text)=>{
const mailOption = {
from :config.EMAIL,
to: recipents,
subject: subject,
text: text
}
return new Promise((resolve, reject)=>{
transporter.sendMail(mailOption, (error, result)=>{
if(error){
reject(error);
}else{
resolve(result);
}
})
})
}
π Exposing the Server for GitHub to Reach
To allow GitHub to reach my local server, I had two options:
- Use a tunneling service like Ngrok
- Deploy to a cloud provider. I chose to deploy to Render, which has a generous free tier and makes deployment super easy. Once deployed, I used the Render URL as the webhook endpoint in GitHub. ---
β Summary
- π We used HMAC with a shared secret to verify webhook authenticity.
- π¦ We used Nodemailer + Gmail to send email notifications.
- π We deployed our app to Render to make it accessible to GitHub.
- π§ And we learned that understanding webhooks at a low level is a great way to grow as a developer.
π Full Source Code
You can view the complete code for this project on GitHub:
π https://github.com/Mehakb78/git-prrequest-mailer
Feel free to clone it, experiment!
Top comments (0)