DevOpsGitHub

GitHub Webhooks and Integrations: Complete Guide

TT
TopicTrick Team
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):

javascript
// 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:

text
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 seconds

Webhooks are faster, cheaper on API limits, and more reliable. They are how essentially all GitHub integrations work in 2026.


Configuring a Webhook

Repository Webhook

  1. Go to your repository on GitHub
  2. Click Settings → Webhooks → Add webhook
  3. Fill in the fields:
FieldWhat to enter
Payload URLThe public URL of your server (e.g., https://api.example.com/webhooks/github)
Content typeapplication/json (always use this)
SecretA random string (at least 32 characters) — used for signature verification
SSL verificationEnabled (your server must have a valid TLS certificate)
EventsChoose which events to receive (see below)
  1. Click Add webhook. GitHub immediately sends a ping event 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

EventWhen it firesCommon use case
pushCommits pushed to any branchTrigger CI, update deployment
pull_requestPR opened, closed, reopened, merged, reviewedNotify team, run checks
issuesIssue opened, closed, assigned, labeledCreate Jira tickets, notify team
releaseRelease created or publishedDeploy to production, update changelog
workflow_runGitHub Actions workflow completesNotify on build failure
createBranch or tag createdNotify on new version tag
deleteBranch or tag deletedCleanup automation
check_runCI check status changesCustom status dashboards
deployment_statusDeployment status updatedPost status to Slack
repositoryRepository settings changedSecurity audit logging

Events That Trigger Frequently (Use Carefully)

EventVolume
pushEvery commit to every branch
check_runMultiple times per CI run
statusEvery 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:

text
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/abc123

JSON payload (example for a push event):

json
{
  "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

javascript
// 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

javascript
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

javascript
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

javascript
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:

  1. Install it at slack.com/apps/A01BP7R4KNY-github
  2. In Slack: /github subscribe owner/repo
  3. Customize: /github subscribe owner/repo pulls reviews comments

For custom Slack notifications from your own webhook handler:

javascript
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.

  1. In Discord, right-click your channel → Edit Channel → Integrations → Webhooks → New Webhook
  2. Copy the webhook URL
  3. GitHub natively supports posting to Discord webhooks — append /github to the URL: https://discord.com/api/webhooks/123456/abc...xyz/github
  4. 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:

javascript
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

  1. Go to your repository Settings → Webhooks
  2. Click on your webhook
  3. Scroll down to Recent Deliveries
  4. Each delivery shows: timestamp, event type, response code, request headers and body, response headers and body

Common Failure Causes

HTTP ResponseCauseFix
Connection refusedServer not running or wrong portStart the server, check port
404 Not FoundWrong URL pathVerify the webhook URL
401 UnauthorizedSignature verification failingCheck the secret value matches exactly
500 Server ErrorException in your handlerCheck server logs, fix the bug
Timeout (no response)Handler taking > 10 secondsRespond 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)

text
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 site

Option 2: ngrok (tunnel to your local server)

bash
# 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 URL

Frequently 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:

bash
gh webhook forward --repo=owner/repo --events=push,pull_request --url=http://localhost:3000/webhooks/github

Key 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.