GitHub Webhooks and Integrations: Complete Guide

GitHub Webhooks and Integrations: Complete Guide
A webhook is an HTTP callback — when something happens in your repository, GitHub sends an HTTP POST to a URL you specify. This lets you build real-time automation without polling: deploy on push, post to Slack on pull request, create Jira tickets on issue creation, trigger load testing on release.
This guide covers the full webhook lifecycle: configuring webhooks, understanding the payload structure, verifying signatures securely, handling the most common events, building a custom handler in Node.js, and debugging failed deliveries.
Poll vs. Push: Why Webhooks Win
Polling approach (naive):
// Check GitHub for new events every 60 seconds
setInterval(async () => {
const events = await fetch('https://api.github.com/repos/org/repo/events');
// Process events...
}, 60000);Problems with polling:
- 60-second delay between event and your reaction
- Consumes GitHub API rate limits (5,000 req/hour for authenticated users)
- Misses events if multiple happen between polls
- Runs continuously even when nothing is happening
Webhook approach:
1. GitHub event occurs (push, PR opened, issue created)
2. GitHub makes HTTP POST to your server within seconds
3. Your server processes the event
4. Total round-trip: 1-3 secondsWebhooks are faster, cheaper on API limits, and more reliable. They are how essentially all GitHub integrations work in 2026.
Configuring a Webhook
Repository Webhook
- Go to your repository on GitHub
- Click Settings → Webhooks → Add webhook
- Fill in the fields:
| Field | What to enter |
|---|---|
| Payload URL | The public URL of your server (e.g., https://api.example.com/webhooks/github) |
| Content type | application/json (always use this) |
| Secret | A random string (at least 32 characters) — used for signature verification |
| SSL verification | Enabled (your server must have a valid TLS certificate) |
| Events | Choose which events to receive (see below) |
- Click Add webhook. GitHub immediately sends a
pingevent to verify the URL is reachable.
Organisation Webhook
Organisation webhooks fire for all repositories in the organisation — useful for org-wide automation like audit logging, security scanning, or cross-repo notifications.
Configure at: github.com/orgs/YOUR-ORG/settings/hooks
Choosing Which Events to Subscribe To
GitHub offers over 50 event types. Subscribe only to what you need — receiving unnecessary events wastes server resources and makes your handler code harder to reason about.
Most Common Events
| Event | When it fires | Common use case |
|---|---|---|
push | Commits pushed to any branch | Trigger CI, update deployment |
pull_request | PR opened, closed, reopened, merged, reviewed | Notify team, run checks |
issues | Issue opened, closed, assigned, labeled | Create Jira tickets, notify team |
release | Release created or published | Deploy to production, update changelog |
workflow_run | GitHub Actions workflow completes | Notify on build failure |
create | Branch or tag created | Notify on new version tag |
delete | Branch or tag deleted | Cleanup automation |
check_run | CI check status changes | Custom status dashboards |
deployment_status | Deployment status updated | Post status to Slack |
repository | Repository settings changed | Security audit logging |
Events That Trigger Frequently (Use Carefully)
| Event | Volume |
|---|---|
push | Every commit to every branch |
check_run | Multiple times per CI run |
status | Every commit status change |
If your repository has many active contributors, subscribing to push will send dozens of webhooks per hour. Design your handler to process them quickly or queue them for asynchronous processing.
Understanding the Webhook Payload
Every webhook delivery includes:
HTTP headers:
X-GitHub-Event: push
X-GitHub-Delivery: abc123de-f456-7890-ghij-klmnopqrstuv
X-Hub-Signature-256: sha256=<hmac-signature>
Content-Type: application/json
User-Agent: GitHub-Hookshot/abc123JSON payload (example for a push event):
{
"ref": "refs/heads/main",
"before": "abc1234",
"after": "def5678",
"repository": {
"id": 123456789,
"name": "my-repo",
"full_name": "my-org/my-repo",
"private": false,
"html_url": "https://github.com/my-org/my-repo"
},
"pusher": {
"name": "alice",
"email": "alice@example.com"
},
"commits": [
{
"id": "def5678",
"message": "feat: add user authentication",
"timestamp": "2026-04-18T10:30:00Z",
"author": {
"name": "Alice Smith",
"email": "alice@example.com",
"username": "alice"
},
"added": ["src/auth/login.ts"],
"removed": [],
"modified": ["src/app.ts", "package.json"]
}
],
"head_commit": { ... }
}The exact structure varies by event type. Always refer to the GitHub Webhook Events documentation for the full payload schema for each event.
Webhook Signature Verification (Critical Security Step)
Without signature verification, anyone can send fake webhook payloads to your server and trigger your automation. This is a serious security risk — an attacker could fake a "push to main" event and trigger your deployment pipeline.
GitHub signs every payload using HMAC-SHA256 with your webhook secret. You must verify this signature before processing any webhook.
Node.js Handler with Signature Verification
// webhooks/github.js — Express.js webhook handler
import express from 'express';
import crypto from 'crypto';
const router = express.Router();
function verifyGitHubSignature(payload, signature, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// IMPORTANT: Use express.raw() for the webhook route, NOT express.json()
// You need the raw body bytes to verify the signature
router.post('/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
const event = req.headers['x-github-event'];
const deliveryId = req.headers['x-github-delivery'];
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature verified — safe to parse the payload
const payload = JSON.parse(req.body.toString());
// Always respond quickly (within 10 seconds) to avoid timeout
res.status(200).json({ received: true, deliveryId });
// Process the event asynchronously
processWebhookEvent(event, payload).catch(err => {
console.error('Webhook processing error:', err);
});
}
);
async function processWebhookEvent(event, payload) {
switch (event) {
case 'push':
await handlePush(payload);
break;
case 'pull_request':
await handlePullRequest(payload);
break;
case 'issues':
await handleIssue(payload);
break;
case 'release':
await handleRelease(payload);
break;
case 'ping':
console.log('Webhook ping received, configuration successful');
break;
default:
console.log(`Unhandled event type: ${event}`);
}
}Critical: Always use crypto.timingSafeEqual() instead of === for comparing signatures. Regular string comparison is vulnerable to timing attacks where an attacker can infer the correct signature by measuring how long the comparison takes.
Handling Common Events
Push Event: Trigger Deployment
async function handlePush(payload) {
const branch = payload.ref.replace('refs/heads/', '');
const pusher = payload.pusher.name;
const commitCount = payload.commits.length;
const headCommit = payload.head_commit;
console.log(`Push to ${branch} by ${pusher}: ${commitCount} commits`);
// Only deploy on push to main
if (branch === 'main') {
await triggerDeployment({
commitSha: headCommit.id,
commitMessage: headCommit.message,
pusher,
});
await notifySlack(
`🚀 Deploying ${commitCount} commit(s) to production\n` +
`Branch: ${branch}\n` +
`By: ${pusher}\n` +
`Message: ${headCommit.message}`
);
}
}Pull Request Event: Post Notifications
async function handlePullRequest(payload) {
const { action, pull_request, repository } = payload;
const pr = pull_request;
// action can be: opened, closed, reopened, edited, assigned,
// review_requested, review_request_removed, labeled, unlabeled, synchronize
if (action === 'opened') {
await notifySlack(
`📋 New PR #${pr.number}: ${pr.title}\n` +
`By: ${pr.user.login}\n` +
`Changes: +${pr.additions} -${pr.deletions} lines\n` +
`URL: ${pr.html_url}`
);
// Auto-assign reviewers based on changed files (CODEOWNERS logic)
await autoAssignReviewers(pr, repository);
}
if (action === 'closed' && pr.merged) {
await recordMergeMetrics({
prNumber: pr.number,
timeToMerge: new Date(pr.merged_at) - new Date(pr.created_at),
reviewCount: pr.reviews?.length ?? 0,
});
}
}Release Event: Production Deployment
async function handleRelease(payload) {
const { action, release } = payload;
if (action === 'published') {
console.log(`Release ${release.tag_name} published: ${release.name}`);
await triggerProductionDeploy(release.tag_name);
// Post release notes to Slack
await notifySlack(
`🎉 Release ${release.tag_name}: ${release.name}\n` +
`${release.body.slice(0, 200)}...\n` +
`Full notes: ${release.html_url}`
);
}
}Slack Integration
The fastest way to get GitHub notifications in Slack is via the official GitHub Slack app:
- Install it at
slack.com/apps/A01BP7R4KNY-github - In Slack:
/github subscribe owner/repo - Customize:
/github subscribe owner/repo pulls reviews comments
For custom Slack notifications from your own webhook handler:
async function notifySlack(message) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: message,
// For richer formatting use blocks:
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: message },
}
]
}),
});
}Discord Integration
For open-source projects, Discord is popular for community notifications.
- In Discord, right-click your channel → Edit Channel → Integrations → Webhooks → New Webhook
- Copy the webhook URL
- GitHub natively supports posting to Discord webhooks — append
/githubto the URL:https://discord.com/api/webhooks/123456/abc...xyz/github - Add this URL as your GitHub webhook payload URL
GitHub sends formatted Discord embeds for push, pull request, issue, and release events automatically.
Responding to Webhooks Within GitHub
Webhook handlers can use the GitHub API to post back to the repository:
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
async function handleIssue(payload) {
const { action, issue, repository } = payload;
if (action === 'opened') {
// Auto-label based on issue title keywords
const title = issue.title.toLowerCase();
const labels = [];
if (title.includes('bug') || title.includes('error') || title.includes('crash')) {
labels.push('bug');
}
if (title.includes('feature') || title.includes('add') || title.includes('support')) {
labels.push('enhancement');
}
if (labels.length > 0) {
await octokit.issues.addLabels({
owner: repository.owner.login,
repo: repository.name,
issue_number: issue.number,
labels,
});
}
// Welcome comment for first-time contributors
const isFirstIssue = issue.author_association === 'FIRST_TIME_CONTRIBUTOR';
if (isFirstIssue) {
await octokit.issues.createComment({
owner: repository.owner.login,
repo: repository.name,
issue_number: issue.number,
body: `Welcome @${issue.user.login}! Thanks for opening your first issue. We'll review it shortly.`,
});
}
}
}Debugging Webhook Deliveries
GitHub logs every webhook delivery and lets you redeliver failed ones.
Viewing Delivery Logs
- Go to your repository Settings → Webhooks
- Click on your webhook
- Scroll down to Recent Deliveries
- Each delivery shows: timestamp, event type, response code, request headers and body, response headers and body
Common Failure Causes
| HTTP Response | Cause | Fix |
|---|---|---|
Connection refused | Server not running or wrong port | Start the server, check port |
404 Not Found | Wrong URL path | Verify the webhook URL |
401 Unauthorized | Signature verification failing | Check the secret value matches exactly |
500 Server Error | Exception in your handler | Check server logs, fix the bug |
Timeout (no response) | Handler taking > 10 seconds | Respond immediately, process async |
Local Development with Webhook.site or ngrok
GitHub cannot reach localhost:3000. For local development:
Option 1: webhook.site (no setup, temporary URL)
1. Visit webhook.site
2. Copy your unique URL
3. Use it as the webhook payload URL in GitHub
4. View incoming payloads in real-time on the siteOption 2: ngrok (tunnel to your local server)
# Install ngrok, then:
ngrok http 3000
# ngrok gives you a public URL like:
# https://abc123.ngrok.io -> localhost:3000
# Use https://abc123.ngrok.io/webhooks/github as your GitHub webhook URLFrequently Asked Questions
Q: Are webhooks secure without a secret?
No. Without a secret, anyone who knows your webhook URL can send fake payloads. Always configure a webhook secret and verify the X-Hub-Signature-256 header using crypto.timingSafeEqual(). Reject any request with a missing or invalid signature with a 401 response.
Q: What happens if my server is down when GitHub sends a webhook?
GitHub retries failed deliveries. After the initial failure, it retries at increasing intervals over approximately 72 hours. After 72 hours of failures, GitHub stops retrying but you can manually redeliver from the "Recent Deliveries" log. Design your webhook handler to be idempotent (safe to call multiple times) since GitHub may deliver the same event more than once.
Q: How do I handle webhooks from multiple repositories?
Use the repository.full_name field in the payload to determine which repo the event came from, then route to the appropriate handler. Or create separate webhook endpoints per repository if the logic is significantly different.
Q: Can I test webhooks locally without exposing my machine to the internet?
Yes, using ngrok or the GitHub CLI's gh webhook forward command. The CLI command forwards GitHub webhook events to your local server:
gh webhook forward --repo=owner/repo --events=push,pull_request --url=http://localhost:3000/webhooks/githubKey Takeaway
Webhooks are the foundation of GitHub automation. The push model gives you real-time, efficient event notifications with no polling overhead. The signature verification mechanism ensures only genuine GitHub events trigger your automation. Build your handler to respond within 10 seconds and process events asynchronously, and you have a reliable real-time integration layer that connects your repository to your deployment pipeline, chat apps, ticketing systems, and any other custom business logic your team needs.
Read next: Securing GitHub Secrets and Environments →
Part of the GitHub Mastery Course — engineering the push.
