Home/Blog/GitHub Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

GitHub Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to GitHub webhooks with setup instructions, payload examples, HMAC-SHA256 signature verification, and implementation code. Learn how to integrate GitHub webhooks for CI/CD automation with step-by-step tutorials.

By Inventive HQ Team
GitHub Webhooks: Complete Guide with Payload Examples [2025]

When a developer pushes code to your repository, opens a pull request, or creates a release, you need to know immediately—not when your polling script runs again in 5 minutes. GitHub webhooks solve this problem by sending real-time HTTP notifications to your server the moment events occur, enabling you to automate CI/CD pipelines, trigger deployments, synchronize issue tracking systems, and integrate with third-party services.

GitHub webhooks are HTTP callbacks that fire when specific events happen in your repositories or organizations. Instead of repeatedly polling the GitHub API to check for changes, your application receives instant notifications, reducing API calls and enabling true real-time integrations.

Common use cases for GitHub webhooks include:

  • CI/CD Automation: Trigger builds, tests, and deployments when code is pushed or pull requests are opened
  • Issue Management: Sync issues and comments with project management tools like Jira or Linear
  • Security Scanning: Run automated security scans when new code is merged
  • Notification Systems: Send team alerts via Slack, Discord, or email when repository events occur
  • Release Management: Automatically publish packages, update documentation, or notify users when releases are created
  • Code Quality: Trigger linting, testing, and code review tools on pull request events
  • Deployment Tracking: Monitor deployment status and update external systems

In this comprehensive guide, you'll learn how to set up GitHub webhooks, verify signatures using HMAC-SHA256, implement production-ready webhook endpoints in multiple languages, and troubleshoot common issues. We'll also show you how to test your webhooks locally using our Webhook Payload Generator tool, which creates properly signed test payloads.

What Are GitHub Webhooks?

GitHub webhooks are event-driven notifications that GitHub sends to your application via HTTP POST requests when specific activities occur in your repositories or organizations. Think of them as GitHub tapping your application on the shoulder and saying, "Hey, something just happened that you care about."

Unlike traditional API polling, where your application repeatedly asks GitHub "Is there anything new?", webhooks flip this model. Instead, GitHub proactively notifies your application only when relevant events occur, making your integration more efficient and responsive.

Here's how the architecture works:

[Repository Event] → [GitHub Servers] → [Your Webhook Endpoint] → [Your Application Logic]

When you push code, open a pull request, or perform other actions, GitHub's servers immediately send an HTTP POST request to your configured webhook URL containing detailed information about the event. Your application receives this payload, verifies its authenticity, and processes it according to your business logic.

Benefits specific to GitHub webhooks:

  • Comprehensive Event Coverage: Access 50+ event types covering every aspect of repository activity, from code changes to issue discussions
  • Rich Payload Data: Receive detailed context about events including commit details, pull request diffs, and user information
  • Cryptographic Security: HMAC-SHA256 signature verification ensures requests originate from GitHub
  • Delivery Monitoring: View webhook delivery history, response codes, and payloads in the GitHub dashboard
  • Manual Redelivery: Redeliver failed webhooks with a single click for debugging and recovery
  • Flexible Scope: Configure webhooks at the repository or organization level

Prerequisites:

  • GitHub account with admin access to a repository or organization
  • A publicly accessible HTTPS endpoint to receive webhooks (HTTP is supported but not recommended)
  • Basic understanding of HTTP requests and JSON

GitHub webhooks work with both GitHub.com and GitHub Enterprise Server, making them suitable for cloud-hosted and self-hosted environments.

Setting Up GitHub Webhooks

Setting up a GitHub webhook takes just a few minutes. Here's the complete step-by-step process with detailed guidance.

Step 1: Navigate to Webhook Settings

For Repository Webhooks:

  1. Go to your GitHub repository's main page
  2. Click the "Settings" tab in the top navigation
  3. Click "Webhooks" in the left sidebar
  4. Click the "Add webhook" button

For Organization Webhooks:

  1. Click your profile icon in the upper-right corner
  2. Select "Your organizations"
  3. Click "Settings" next to your organization
  4. Click "Webhooks" in the left sidebar
  5. Click "Add webhook" button

You'll be prompted to confirm your password before proceeding.

Step 2: Configure Payload URL

In the "Payload URL" field, enter the full HTTPS URL where GitHub should send webhook payloads. This is your application's webhook endpoint.

https://yourdomain.com/webhooks/github

Important considerations:

  • Use HTTPS for production (HTTP is allowed but insecure)
  • The endpoint must be publicly accessible from GitHub's servers
  • Include any necessary path segments or subdomains
  • Avoid using ports other than 443 (HTTPS) or 80 (HTTP) when possible

Step 3: Select Content Type

Choose how GitHub should format the webhook payload:

  • application/json (Recommended): Sends the JSON payload directly as the POST request body. This is the most common choice and easiest to work with in modern frameworks.
  • application/x-www-form-urlencoded: Sends the payload as a form parameter named payload. Useful for legacy systems expecting form data.

Step 4: Set Webhook Secret

Click "Add secret" and enter a random, high-entropy string. This secret is used to compute HMAC-SHA256 signatures that verify webhook authenticity.

Generating a secure secret:

# Linux/macOS
openssl rand -hex 32

# Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# Python
python3 -c "import secrets; print(secrets.token_hex(32))"

Critical: Store this secret securely in your application's environment variables. You'll need it to verify webhook signatures. Never commit secrets to version control.

Step 5: Select Events

Choose which events should trigger your webhook:

Just the push event (Default): Only sends webhooks when code is pushed to the repository.

Send me everything: Subscribes to all current and future events. This generates significant traffic and is rarely needed.

Let me select individual events: Choose specific events to minimize unnecessary webhook deliveries. Recommended for most use cases.

For CI/CD workflows, commonly selected events include:

  • push
  • pull_request
  • release
  • workflow_run
  • deployment

For issue tracking integrations, select:

  • issues
  • issue_comment
  • pull_request_review
  • pull_request_review_comment

Step 6: Enable the Webhook

Ensure the "Active" checkbox is checked. This enables the webhook immediately upon creation. You can disable it later without deleting the configuration.

Step 7: Create and Test

Click "Add webhook". GitHub immediately sends a "ping" event to verify your endpoint is reachable. If successful, you'll see a green checkmark next to your webhook. If it fails, you'll see a red X with error details.

Viewing Webhook Deliveries

After creating your webhook:

  1. Click on the webhook from the Webhooks list
  2. Scroll to the "Recent Deliveries" section
  3. Click any delivery to view:
    • Request headers and payload
    • Response headers and body
    • Status code and timing information
    • Option to redeliver the webhook

Pro Tips:

  • Test Before Production: Set up webhooks in a test repository first to validate your endpoint works correctly
  • Monitor Recent Deliveries: Regularly check the Recent Deliveries section for failed webhooks, especially after deploying changes
  • Use Descriptive URLs: Include the provider name in your webhook path (e.g., /webhooks/github) if you handle multiple webhook sources
  • Start Narrow: Subscribe only to events you need initially. You can always add more events later
  • Document Your Secret: Store your webhook secret location in your team's documentation

GitHub Webhook Events & Payloads

GitHub supports over 50 webhook events covering every aspect of repository and organization activity. Understanding these events and their payloads is crucial for building effective integrations.

Event Overview

Event TypeDescriptionCommon Use Case
pushCode pushed to repositoryTrigger CI/CD builds and deployments
pull_requestPull request activityRun automated tests and code reviews
releaseRelease published or updatedDeploy to production and notify users
issuesIssue opened, edited, or closedSync with project management tools
issue_commentComment on issue or PRMonitor discussions and extract feedback
workflow_runGitHub Actions workflow executedTrigger dependent workflows and monitoring
deploymentDeployment createdOrchestrate deployment pipelines
createBranch or tag createdTrack repository growth and branching
deleteBranch or tag deletedClean up environments and resources
statusCommit status updatedMonitor CI status from external systems

Detailed Event Examples

Event: push

Description: Triggered when commits are pushed to a repository or when tags are pushed.

Payload Structure:

{
  "ref": "refs/heads/main",
  "before": "0000000000000000000000000000000000000000",
  "after": "a747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
  "repository": {
    "id": 123456789,
    "name": "my-repo",
    "full_name": "username/my-repo",
    "private": false,
    "owner": {
      "name": "username",
      "email": "[email protected]"
    },
    "html_url": "https://github.com/username/my-repo"
  },
  "pusher": {
    "name": "username",
    "email": "[email protected]"
  },
  "sender": {
    "login": "username",
    "id": 12345,
    "avatar_url": "https://avatars.githubusercontent.com/u/12345"
  },
  "created": false,
  "deleted": false,
  "forced": false,
  "base_ref": null,
  "compare": "https://github.com/username/my-repo/compare/0000000...a747a7f",
  "commits": [
    {
      "id": "a747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
      "tree_id": "b747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
      "message": "Add new feature",
      "timestamp": "2025-01-24T10:30:00-05:00",
      "author": {
        "name": "John Doe",
        "email": "[email protected]",
        "username": "johndoe"
      },
      "committer": {
        "name": "John Doe",
        "email": "[email protected]",
        "username": "johndoe"
      },
      "added": ["src/feature.js"],
      "removed": [],
      "modified": ["package.json"]
    }
  ],
  "head_commit": {
    "id": "a747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
    "message": "Add new feature",
    "timestamp": "2025-01-24T10:30:00-05:00",
    "author": {
      "name": "John Doe",
      "email": "[email protected]"
    }
  }
}

Key Fields:

  • ref - Git reference that was pushed (e.g., refs/heads/main for branches)
  • before - Commit SHA before the push
  • after - Commit SHA after the push (use this to determine what changed)
  • commits - Array of all commits included in this push
  • head_commit - The most recent commit on the branch
  • pusher - User who performed the push
  • created - True if this push created a new branch
  • deleted - True if this push deleted a branch
  • forced - True if this was a force push

Use Case: Trigger CI/CD pipelines to build and test code when developers push to specific branches.

Event: pull_request

Description: Triggered when a pull request is opened, closed, reopened, edited, assigned, labeled, or synchronized (new commits pushed).

Payload Structure:

{
  "action": "opened",
  "number": 42,
  "pull_request": {
    "id": 987654321,
    "number": 42,
    "state": "open",
    "locked": false,
    "title": "Add user authentication",
    "user": {
      "login": "contributor",
      "id": 54321
    },
    "body": "This PR implements user authentication using JWT tokens.",
    "created_at": "2025-01-24T10:00:00Z",
    "updated_at": "2025-01-24T10:00:00Z",
    "head": {
      "ref": "feature/auth",
      "sha": "c747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
      "repo": {
        "name": "my-repo",
        "full_name": "contributor/my-repo"
      }
    },
    "base": {
      "ref": "main",
      "sha": "a747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
      "repo": {
        "name": "my-repo",
        "full_name": "username/my-repo"
      }
    },
    "merged": false,
    "mergeable": true,
    "mergeable_state": "clean",
    "draft": false
  },
  "repository": {
    "id": 123456789,
    "name": "my-repo",
    "full_name": "username/my-repo"
  },
  "sender": {
    "login": "contributor",
    "id": 54321
  }
}

Key Fields:

  • action - What happened (opened, closed, reopened, edited, synchronize, etc.)
  • number - Pull request number for easy reference
  • pull_request.state - Current state (open, closed)
  • pull_request.merged - Whether the PR was merged (check this when action is "closed")
  • pull_request.head - Branch being merged from (source branch)
  • pull_request.base - Branch being merged into (target branch)
  • pull_request.draft - Whether this is a draft PR
  • pull_request.mergeable_state - Whether the PR can be merged (clean, dirty, unstable)

Use Case: Run automated tests, security scans, and code quality checks when pull requests are opened or updated.

Event: release

Description: Triggered when a release is published, unpublished, created, edited, or deleted.

Payload Structure:

{
  "action": "published",
  "release": {
    "id": 123456,
    "tag_name": "v1.0.0",
    "target_commitish": "main",
    "name": "Version 1.0.0",
    "draft": false,
    "prerelease": false,
    "created_at": "2025-01-24T10:00:00Z",
    "published_at": "2025-01-24T10:05:00Z",
    "assets": [
      {
        "id": 654321,
        "name": "app-v1.0.0.zip",
        "content_type": "application/zip",
        "size": 1048576,
        "download_count": 0,
        "browser_download_url": "https://github.com/username/my-repo/releases/download/v1.0.0/app-v1.0.0.zip"
      }
    ],
    "tarball_url": "https://api.github.com/repos/username/my-repo/tarball/v1.0.0",
    "zipball_url": "https://api.github.com/repos/username/my-repo/zipball/v1.0.0",
    "body": "## What's New\n\n- Added feature X\n- Fixed bug Y",
    "author": {
      "login": "username",
      "id": 12345
    }
  },
  "repository": {
    "id": 123456789,
    "name": "my-repo",
    "full_name": "username/my-repo"
  },
  "sender": {
    "login": "username",
    "id": 12345
  }
}

Key Fields:

  • action - What happened to the release (published, created, edited, deleted)
  • release.tag_name - Git tag associated with this release
  • release.name - Human-readable release name
  • release.draft - Whether this is a draft (not publicly visible)
  • release.prerelease - Whether this is marked as a pre-release
  • release.assets - Array of uploaded release assets (binaries, installers, etc.)
  • release.body - Release notes in markdown format

Use Case: Trigger production deployments, publish packages to npm/PyPI, send release announcements, or update documentation when new versions are released.

Event: issues

Description: Triggered when an issue is opened, edited, closed, reopened, assigned, labeled, or unlabeled.

Payload Structure:

{
  "action": "opened",
  "issue": {
    "id": 111111111,
    "number": 123,
    "title": "Bug: Login form not validating email",
    "user": {
      "login": "reporter",
      "id": 98765
    },
    "labels": [
      {
        "id": 1,
        "name": "bug",
        "color": "d73a4a"
      }
    ],
    "state": "open",
    "locked": false,
    "assignee": null,
    "assignees": [],
    "comments": 0,
    "created_at": "2025-01-24T10:00:00Z",
    "updated_at": "2025-01-24T10:00:00Z",
    "closed_at": null,
    "body": "When I enter an invalid email, the form still submits..."
  },
  "repository": {
    "id": 123456789,
    "name": "my-repo",
    "full_name": "username/my-repo"
  },
  "sender": {
    "login": "reporter",
    "id": 98765
  }
}

Key Fields:

  • action - Issue activity type (opened, closed, edited, assigned, labeled, etc.)
  • issue.number - Issue number for referencing
  • issue.state - Current state (open or closed)
  • issue.labels - Array of labels applied to the issue
  • issue.assignees - Users assigned to this issue
  • issue.body - Issue description in markdown

Use Case: Sync issues with external project management tools, trigger automated triage workflows, or send team notifications.

Event: workflow_run

Description: Triggered when a GitHub Actions workflow run is requested or completed.

Payload Structure:

{
  "action": "completed",
  "workflow_run": {
    "id": 123456789,
    "name": "CI",
    "head_branch": "main",
    "head_sha": "a747a7f8ccf4e2f9e3b9b5e5e5c3e3b9e3e3e3e3",
    "run_number": 42,
    "event": "push",
    "status": "completed",
    "conclusion": "success",
    "workflow_id": 654321,
    "created_at": "2025-01-24T10:00:00Z",
    "updated_at": "2025-01-24T10:05:00Z",
    "run_started_at": "2025-01-24T10:00:00Z",
    "jobs_url": "https://api.github.com/repos/username/my-repo/actions/runs/123456789/jobs",
    "logs_url": "https://api.github.com/repos/username/my-repo/actions/runs/123456789/logs",
    "html_url": "https://github.com/username/my-repo/actions/runs/123456789"
  },
  "workflow": {
    "id": 654321,
    "name": "CI",
    "path": ".github/workflows/ci.yml"
  },
  "repository": {
    "id": 123456789,
    "name": "my-repo",
    "full_name": "username/my-repo"
  },
  "sender": {
    "login": "username",
    "id": 12345
  }
}

Key Fields:

  • action - Workflow state change (requested or completed)
  • workflow_run.status - Current status (queued, in_progress, completed)
  • workflow_run.conclusion - Final result (success, failure, cancelled, skipped)
  • workflow_run.event - What triggered the workflow (push, pull_request, etc.)
  • workflow_run.run_number - Sequential run number
  • workflow.path - Path to the workflow YAML file

Use Case: Monitor CI/CD pipeline status, trigger dependent workflows, update deployment dashboards, or send build status notifications.

Webhook Signature Verification

Verifying webhook signatures is critical for security. Without verification, any attacker could send fake webhook payloads to your endpoint, potentially triggering unintended actions or exposing sensitive information.

Why It Matters

Signature verification ensures:

  • Authenticity: Requests actually come from GitHub, not an impersonator
  • Integrity: Payloads haven't been modified in transit
  • Prevention of Replay Attacks: When combined with timestamp validation

Without signature verification, attackers could:

  • Trigger unauthorized deployments
  • Manipulate your issue tracking or project management integrations
  • Cause denial of service by flooding your endpoint
  • Inject malicious data into your systems

GitHub's Signature Method

GitHub uses HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256) to cryptographically sign webhook payloads:

  • Algorithm: HMAC-SHA256
  • Header Name: X-Hub-Signature-256 (contains the signature with sha256= prefix)
  • Legacy Header: X-Hub-Signature (SHA1, deprecated but still supported)
  • What's Signed: The raw request body (before any parsing)
  • Secret: The webhook secret you configured in GitHub settings

Additional Headers

GitHub includes several headers with every webhook delivery:

  • X-GitHub-Event - Event type (e.g., "push", "pull_request")
  • X-GitHub-Delivery - Unique GUID for this delivery (use for idempotency)
  • X-Hub-Signature-256 - HMAC-SHA256 signature
  • X-Hub-Signature - HMAC-SHA1 signature (legacy, deprecated)
  • User-Agent - Always starts with "GitHub-Hookshot/"

Step-by-Step Verification Process

  1. Extract the signature from the X-Hub-Signature-256 header
  2. Retrieve your webhook secret from environment variables
  3. Compute the expected signature using HMAC-SHA256 with the secret and raw body
  4. Compare signatures using a constant-time comparison function
  5. Validate the timestamp (optional but recommended to prevent replay attacks)

Code Examples

Node.js / Express

const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANT: Use raw body parser for signature verification
// Must preserve exact bytes for signature to match
app.use('/webhooks/github', express.raw({type: 'application/json'}));

app.post('/webhooks/github', (req, res) => {
  // Extract signature from header
  const signature = req.headers['x-hub-signature-256'];
  const secret = process.env.GITHUB_WEBHOOK_SECRET;

  if (!signature) {
    console.error('No X-Hub-Signature-256 header found');
    return res.status(401).send('Unauthorized');
  }

  // Compute expected signature
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  // Verify signature using timing-safe comparison
  // IMPORTANT: Use timingSafeEqual to prevent timing attacks
  try {
    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      console.error('Invalid signature');
      return res.status(401).send('Unauthorized');
    }
  } catch (error) {
    // timingSafeEqual throws if buffer lengths differ
    console.error('Signature verification failed:', error.message);
    return res.status(401).send('Unauthorized');
  }

  // Parse payload AFTER verification
  const payload = JSON.parse(req.body.toString());
  const event = req.headers['x-github-event'];
  const deliveryId = req.headers['x-github-delivery'];

  console.log(`Received ${event} event (delivery: ${deliveryId})`);

  // Return 200 immediately (GitHub expects response within 10 seconds)
  res.status(200).send('Webhook received');

  // Process webhook asynchronously
  processWebhookAsync(event, payload, deliveryId);
});

async function processWebhookAsync(event, payload, deliveryId) {
  // Your business logic here
  console.log('Processing webhook:', event);
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});

Python / Flask

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret_here'

@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
    # Get signature from headers
    signature = request.headers.get('X-Hub-Signature-256')

    if not signature:
        abort(401, 'Missing X-Hub-Signature-256 header')

    # Get raw body (important: before any parsing)
    payload = request.get_data()

    # Compute expected signature
    expected_signature = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Verify signature using constant-time comparison
    # IMPORTANT: Use hmac.compare_digest to prevent timing attacks
    if not hmac.compare_digest(signature, expected_signature):
        abort(401, 'Invalid signature')

    # Parse payload after verification
    data = request.get_json()
    event = request.headers.get('X-GitHub-Event')
    delivery_id = request.headers.get('X-GitHub-Delivery')

    print(f"Received {event} event (delivery: {delivery_id})")

    # Process webhook
    process_webhook(event, data, delivery_id)

    # Return 200 immediately
    return 'Webhook received', 200

def process_webhook(event, data, delivery_id):
    """Process webhook asynchronously"""
    # Your business logic here
    print(f"Processing webhook: {event}")

if __name__ == '__main__':
    app.run(port=3000)

PHP

<?php
$secret = getenv('GITHUB_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';

if (empty($signature)) {
    http_response_code(401);
    die('Missing X-Hub-Signature-256 header');
}

// Get raw POST body
$payload = file_get_contents('php://input');

// Compute expected signature
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);

// Verify signature using constant-time comparison
// IMPORTANT: Use hash_equals to prevent timing attacks
if (!hash_equals($signature, $expectedSignature)) {
    http_response_code(401);
    die('Invalid signature');
}

// Parse payload after verification
$data = json_decode($payload, true);
$event = $_SERVER['HTTP_X_GITHUB_EVENT'] ?? 'unknown';
$deliveryId = $_SERVER['HTTP_X_GITHUB_DELIVERY'] ?? 'unknown';

error_log("Received $event event (delivery: $deliveryId)");

// Process webhook
processWebhook($event, $data, $deliveryId);

// Return 200 immediately
http_response_code(200);
echo 'Webhook received';

function processWebhook($event, $data, $deliveryId) {
    // Your business logic here
    error_log("Processing webhook: $event");
}
?>

Common Verification Errors

Avoid these common mistakes that break signature verification:

  • Parsing JSON before verification: Once you parse the body, the raw bytes change. Always verify against the raw body, then parse.
  • Wrong secret: Using test mode secret with production webhooks or vice versa.
  • Not using constant-time comparison: Regular equality (==, ===) is vulnerable to timing attacks. Always use crypto.timingSafeEqual(), hmac.compare_digest(), or hash_equals().
  • Wrong algorithm: Using SHA1 instead of SHA256 or computing HMAC incorrectly.
  • Encoding issues: Ensure both signature computation and comparison use the same encoding (UTF-8).
  • Forgetting the prefix: GitHub's signature includes the sha256= prefix in the header value.

Testing GitHub Webhooks

Testing webhooks during development presents unique challenges since GitHub's servers need to reach your application via HTTP, but your development environment typically runs on localhost, which isn't accessible from the internet.

Local Development Challenges

When developing locally, you face these obstacles:

  • GitHub can't reach http://localhost:3000 from their servers
  • Need a publicly accessible URL with valid SSL
  • Setting up proper HTTPS certificates locally is complex
  • Want to iterate quickly without deploying after every change

Solution 1: ngrok for Local Tunneling

ngrok creates a secure tunnel from a public URL to your local development server, allowing GitHub to reach your localhost.

Setup:

# Install ngrok
# macOS
brew install ngrok

# Windows (with Chocolatey)
choco install ngrok

# Or download from https://ngrok.com/download

# Start your local server first (example: port 3000)
npm run dev
# or
node server.js

# In another terminal, expose localhost via ngrok
ngrok http 3000

Output:

ngrok

Session Status                online
Account                       your-account (Plan: Free)
Version                       3.5.0
Region                        United States (us)
Latency                       -
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://abc123def456.ngrok-free.app -> http://localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

Using the ngrok URL:

  1. Copy the HTTPS forwarding URL (e.g., https://abc123def456.ngrok-free.app)
  2. In GitHub webhook settings, set your Payload URL to: https://abc123def456.ngrok-free.app/webhooks/github
  3. Save the webhook
  4. Trigger a test event (like pushing a commit)
  5. Watch requests appear in both ngrok's dashboard (http://127.0.0.1:4040) and your server logs

Benefits:

  • See request/response details in ngrok's web interface
  • Automatic HTTPS with valid certificates
  • Inspect and replay webhook deliveries
  • Free tier available (with some limitations)

Limitations:

  • URL changes every time ngrok restarts (paid plans offer persistent URLs)
  • Free tier has request limits
  • Adds slight latency
  • Requires updating GitHub settings if URL changes

Solution 2: Webhook Payload Generator Tool

For faster testing without tunneling complexity, use our Webhook Payload Generator:

Steps:

  1. Visit Webhook Payload Generator
  2. Select "GitHub" from the provider dropdown
  3. Choose your event type (e.g., push, pull_request, release)
  4. Customize payload fields to match your test scenario
  5. Enter your webhook secret
  6. Click "Generate Payload"
  7. Copy the signed payload and headers
  8. Send to your local endpoint using curl or Postman

Example using curl:

# Copy the signature from the tool
curl -X POST http://localhost:3000/webhooks/github \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -H "X-GitHub-Delivery: 12345678-1234-1234-1234-123456789012" \
  -H "X-Hub-Signature-256: sha256=YOUR_GENERATED_SIGNATURE" \
  -d @payload.json

Benefits:

  • No tunneling required
  • Test signature verification logic thoroughly
  • Customize payload values for edge cases
  • Test error handling with invalid signatures
  • Faster iteration than triggering real GitHub events
  • Works completely offline

GitHub's Testing Features

GitHub provides built-in testing capabilities:

Manual Webhook Redelivery:

  1. Go to your webhook settings
  2. Scroll to "Recent Deliveries"
  3. Click any delivery
  4. Click "Redeliver" to send it again
  5. Useful for debugging and retesting failed deliveries

Ping Event: When you create or edit a webhook, GitHub automatically sends a "ping" event to verify connectivity. This is the simplest way to test if your endpoint is reachable.

Response Code Monitoring: The Recent Deliveries section shows:

  • HTTP status code your endpoint returned
  • Response body
  • Full request headers and payload
  • Response time

Testing Checklist

Before deploying to production, verify:

  • Signature verification passes with correct secret
  • Endpoint returns 200 within 10 seconds
  • Idempotent handling (processing same delivery ID multiple times doesn't cause issues)
  • Error handling for malformed payloads, missing headers, and unexpected event types
  • Async processing (webhook response doesn't wait for long-running tasks)
  • Proper logging of delivery IDs, events, and any errors
  • Different event types work correctly (test push, pull_request, issues, etc.)
  • Edge cases like force pushes, deleted branches, and empty commits

Debugging Tips

Check Recent Deliveries: The Recent Deliveries section in GitHub is your first debugging stop. Look for:

  • Non-200 status codes
  • Error messages in response bodies
  • Timeout errors (10+ second response time)

Verify Your Secret: Test signature verification separately:

# Use the Webhook Payload Generator tool with your secret
# Send to your endpoint
# Check logs for signature verification errors

Test Locally First: Use the Webhook Payload Generator to test locally before deploying, ensuring your logic works before connecting to GitHub.

Monitor Logs: Add detailed logging to see:

  • Received signatures vs computed signatures
  • Event types being processed
  • Delivery IDs for tracking
  • Any exceptions or errors

Implementation Example

Here's a complete, production-ready GitHub webhook endpoint implementation that demonstrates best practices including signature verification, idempotency, async processing, and comprehensive error handling.

Requirements

A production-ready webhook endpoint must:

  • Respond quickly: Return 200 within 10 seconds (GitHub's timeout)
  • Verify signatures: Reject unauthorized requests
  • Handle idempotency: Process each delivery exactly once, even if GitHub resends
  • Process asynchronously: Don't block the response while doing heavy work
  • Handle errors gracefully: Return 200 even if processing fails to prevent unnecessary retries
  • Log comprehensively: Track all deliveries for debugging and monitoring

Complete Node.js Implementation

const express = require('express');
const crypto = require('crypto');
const Queue = require('bull'); // npm install bull
const Redis = require('ioredis');

const app = express();
const redis = new Redis();
const webhookQueue = new Queue('github-webhooks', {
  redis: { host: 'localhost', port: 6379 }
});

// Parse raw body for signature verification
app.use('/webhooks/github', express.raw({type: 'application/json'}));

// GitHub webhook endpoint
app.post('/webhooks/github', async (req, res) => {
  try {
    // 1. Extract headers
    const signature = req.headers['x-hub-signature-256'];
    const event = req.headers['x-github-event'];
    const deliveryId = req.headers['x-github-delivery'];

    // 2. Verify signature
    const secret = process.env.GITHUB_WEBHOOK_SECRET;

    if (!signature) {
      console.error('Missing signature header');
      return res.status(401).json({ error: 'Unauthorized' });
    }

    const expectedSignature = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      console.error('Invalid signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 3. Parse payload after verification
    const payload = JSON.parse(req.body.toString());

    // 4. Check for duplicate (idempotency)
    const exists = await redis.get(`github:delivery:${deliveryId}`);
    if (exists) {
      console.log(`Delivery ${deliveryId} already processed, skipping`);
      return res.status(200).json({ received: true, duplicate: true });
    }

    // 5. Mark as received (expires after 7 days)
    await redis.setex(`github:delivery:${deliveryId}`, 604800, 'true');

    // 6. Queue for async processing
    await webhookQueue.add({
      deliveryId,
      event,
      payload
    }, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000
      }
    });

    // 7. Return 200 immediately
    res.status(200).json({ received: true });

    console.log(`Queued ${event} event (delivery: ${deliveryId})`);

  } catch (error) {
    console.error('Webhook endpoint error:', error);
    // Still return 200 to prevent retries for our internal errors
    res.status(200).json({ received: true, error: true });
  }
});

// Process webhooks from queue
webhookQueue.process(async (job) => {
  const { deliveryId, event, payload } = job.data;

  console.log(`Processing ${event} event (delivery: ${deliveryId})`);

  try {
    // Handle different event types
    switch (event) {
      case 'push':
        await handlePushEvent(payload);
        break;

      case 'pull_request':
        await handlePullRequestEvent(payload);
        break;

      case 'release':
        await handleReleaseEvent(payload);
        break;

      case 'issues':
        await handleIssuesEvent(payload);
        break;

      case 'workflow_run':
        await handleWorkflowRunEvent(payload);
        break;

      case 'ping':
        console.log('Received ping event');
        break;

      default:
        console.warn(`Unhandled event type: ${event}`);
    }

    console.log(`Successfully processed ${event} (delivery: ${deliveryId})`);

  } catch (error) {
    console.error(`Failed to process ${event} (delivery: ${deliveryId}):`, error);
    throw error; // Will trigger queue retry
  }
});

// Business logic handlers

async function handlePushEvent(payload) {
  const ref = payload.ref;
  const repo = payload.repository.full_name;
  const commits = payload.commits.length;

  console.log(`Push to ${repo} on ${ref}: ${commits} commit(s)`);

  // Only build on main branch
  if (ref !== 'refs/heads/main') {
    console.log('Not main branch, skipping build');
    return;
  }

  // Trigger CI/CD pipeline
  await triggerBuild({
    repo,
    branch: ref.replace('refs/heads/', ''),
    commit: payload.after,
    pusher: payload.pusher.name
  });
}

async function handlePullRequestEvent(payload) {
  const action = payload.action;
  const prNumber = payload.number;
  const repo = payload.repository.full_name;

  console.log(`PR #${prNumber} ${action} in ${repo}`);

  // Run tests on opened or synchronized (new commits)
  if (action === 'opened' || action === 'synchronize') {
    await triggerPRChecks({
      repo,
      prNumber,
      headSha: payload.pull_request.head.sha,
      baseBranch: payload.pull_request.base.ref
    });
  }

  // Send notification when PR is ready for review
  if (action === 'ready_for_review') {
    await notifyTeam({
      type: 'pr_ready',
      repo,
      prNumber,
      prTitle: payload.pull_request.title,
      author: payload.pull_request.user.login
    });
  }
}

async function handleReleaseEvent(payload) {
  const action = payload.action;
  const tagName = payload.release.tag_name;
  const repo = payload.repository.full_name;

  console.log(`Release ${tagName} ${action} in ${repo}`);

  // Deploy to production when release is published
  if (action === 'published' && !payload.release.prerelease) {
    await deployToProduction({
      repo,
      version: tagName,
      releaseUrl: payload.release.html_url
    });
  }
}

async function handleIssuesEvent(payload) {
  const action = payload.action;
  const issueNumber = payload.issue.number;
  const repo = payload.repository.full_name;

  console.log(`Issue #${issueNumber} ${action} in ${repo}`);

  // Sync with project management tool
  await syncIssueWithProjectManagementTool({
    action,
    issueNumber,
    title: payload.issue.title,
    body: payload.issue.body,
    labels: payload.issue.labels.map(l => l.name),
    state: payload.issue.state,
    assignees: payload.issue.assignees.map(a => a.login)
  });
}

async function handleWorkflowRunEvent(payload) {
  const action = payload.action;
  const workflowName = payload.workflow.name;
  const status = payload.workflow_run.status;
  const conclusion = payload.workflow_run.conclusion;

  console.log(`Workflow "${workflowName}" ${action} (${status}/${conclusion})`);

  // Only process completed workflows
  if (action !== 'completed') {
    return;
  }

  // Send notification on failure
  if (conclusion === 'failure') {
    await notifyTeam({
      type: 'workflow_failed',
      workflowName,
      runUrl: payload.workflow_run.html_url,
      branch: payload.workflow_run.head_branch
    });
  }

  // Update deployment dashboard
  await updateDeploymentDashboard({
    workflowName,
    status: conclusion,
    duration: calculateDuration(
      payload.workflow_run.run_started_at,
      payload.workflow_run.updated_at
    )
  });
}

// Helper functions

async function triggerBuild({ repo, branch, commit, pusher }) {
  console.log(`Triggering build for ${repo}@${commit}`);
  // Your CI/CD integration here
}

async function triggerPRChecks({ repo, prNumber, headSha, baseBranch }) {
  console.log(`Running PR checks for ${repo}#${prNumber}`);
  // Your test automation here
}

async function deployToProduction({ repo, version, releaseUrl }) {
  console.log(`Deploying ${repo} ${version} to production`);
  // Your deployment logic here
}

async function syncIssueWithProjectManagementTool(issueData) {
  console.log(`Syncing issue #${issueData.issueNumber}`);
  // Your project management integration here
}

async function notifyTeam(notification) {
  console.log('Sending team notification:', notification.type);
  // Your notification logic (Slack, email, etc.)
}

async function updateDeploymentDashboard(data) {
  console.log('Updating deployment dashboard');
  // Your monitoring dashboard update here
}

function calculateDuration(startTime, endTime) {
  const start = new Date(startTime);
  const end = new Date(endTime);
  return Math.round((end - start) / 1000); // Duration in seconds
}

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM signal received: closing HTTP server');
  await webhookQueue.close();
  await redis.quit();
  process.exit(0);
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`GitHub webhook server listening on port ${PORT}`);
});

Key Implementation Details

1. Raw Body Parsing Use express.raw() instead of express.json() for the webhook route. Signature verification requires the exact raw bytes as GitHub sent them. JSON parsing modifies the body, breaking signature verification.

2. Timing-Safe Comparison Always use crypto.timingSafeEqual() for signature comparison. Regular equality operators (===) are vulnerable to timing attacks where attackers measure response time differences to guess the signature.

3. Idempotency Check Store delivery IDs in Redis (or a database) to detect and skip duplicate deliveries. GitHub may send the same webhook multiple times if your endpoint times out or returns an error. Processing duplicates can cause serious issues like double charges or duplicate deployments.

4. Queue-Based Processing Use a job queue (Bull, BullMQ, AWS SQS, etc.) to process webhooks asynchronously. This ensures:

  • Fast response times (< 10 seconds)
  • Automatic retries on failure
  • Rate limiting and concurrency control
  • Persistence across server restarts

5. Error Handling Return 200 even if processing fails initially. Throwing errors causes GitHub to mark the delivery as failed, but you've already queued it for processing. Let your queue handle retries instead.

6. Comprehensive Logging Log delivery IDs, event types, and processing results. This makes debugging much easier when investigating issues or tracking specific webhook deliveries.

Best Practices

Follow these best practices to build secure, reliable, and performant GitHub webhook integrations.

Security

Always verify signatures: Never skip signature verification, even in development. It's the only way to ensure requests are genuinely from GitHub.

Use HTTPS endpoints only: While GitHub supports HTTP, always use HTTPS in production. This prevents man-in-the-middle attacks and payload interception.

Store secrets in environment variables: Never hardcode secrets in your codebase or commit them to version control. Use environment variables or a secrets management system like AWS Secrets Manager or HashiCorp Vault.

Validate timestamp to prevent replay attacks: While GitHub doesn't include timestamps in the signature, you can check the webhook's "created_at" field and reject payloads older than 5 minutes.

Rate limit webhook endpoints: Implement rate limiting to protect against potential abuse or denial of service attacks, even though requests should be verified.

Use separate secrets for test and production: Configure different webhook secrets for development, staging, and production environments to prevent cross-contamination.

Performance

Respond within 10 seconds: GitHub times out webhook deliveries after 10 seconds. Always return 200 immediately and process asynchronously.

Return 200 immediately, process async: Acknowledge receipt with HTTP 200, then queue the webhook for background processing. Don't wait for database queries, API calls, or complex business logic.

Use queue systems: Implement message queues (Redis Queue, Bull, RabbitMQ, AWS SQS) to handle webhook processing. This provides automatic retries, rate limiting, and horizontal scaling.

Implement exponential backoff: When processing webhooks requires calling external APIs, use exponential backoff retry logic to handle temporary failures gracefully.

Monitor processing times: Track how long webhook processing takes. Set up alerts if processing consistently takes longer than expected, indicating performance issues.

Reliability

Implement idempotency: Store delivery IDs and check for duplicates before processing. GitHub may send the same webhook multiple times if your endpoint fails or times out.

Handle duplicate webhooks gracefully: Design your business logic to be idempotent. Processing the same webhook twice should not cause issues like double deployments or duplicate database records.

Don't rely solely on webhooks: Implement periodic reconciliation jobs that fetch data from GitHub's API to catch any missed webhooks due to network issues or your endpoint being down.

Implement retry logic for failed processing: When webhook processing fails (e.g., database unavailable), use your queue's retry mechanism with exponential backoff rather than discarding the webhook.

Log all webhook events: Maintain comprehensive logs of all received webhooks, including delivery IDs, events, processing status, and any errors. This is invaluable for debugging.

Monitoring

Track webhook delivery success rate: Monitor the percentage of successful vs failed deliveries in GitHub's Recent Deliveries section. Sudden drops indicate problems with your endpoint.

Alert on signature verification failures: Set up alerts for signature verification failures. A spike could indicate an attack attempt or a configuration error.

Monitor processing queue depth: Track how many webhooks are queued for processing. Growing queues indicate processing is slower than webhook arrival rate.

Log event IDs for traceability: Always log the X-GitHub-Delivery ID. This allows you to correlate webhook deliveries with processing logs and GitHub's delivery history.

Set up health checks: Implement a health check endpoint that verifies your webhook processing pipeline is operational, including queue connectivity and database access.

GitHub-Specific Best Practices

Subscribe only to needed events: Don't select "Send me everything." Only subscribe to events your application uses. This reduces bandwidth, processing overhead, and potential security exposure.

Check event types before processing: Always verify the X-GitHub-Event header matches expected event types before processing. Ignore unexpected events rather than erroring.

Use delivery IDs for idempotency: The X-GitHub-Delivery header provides a unique GUID for each delivery. Store these IDs to detect and handle duplicate deliveries.

Understand GitHub doesn't guarantee ordering: Webhooks may arrive out of order. Design your logic to handle this, using timestamps to determine event sequence when necessary.

Monitor Recent Deliveries regularly: Check the Recent Deliveries section in your GitHub webhook settings weekly to catch and investigate any failed deliveries.

Test webhook changes in a separate repository: Create a test repository to experiment with webhook configurations and new event handlers before modifying production webhooks.

Common Issues & Troubleshooting

Even with careful implementation, you may encounter issues with GitHub webhooks. Here are the most common problems and their solutions.

Issue 1: Signature Verification Failing

Symptoms:

  • Your endpoint returns 401 Unauthorized
  • GitHub's Recent Deliveries show failed deliveries
  • Logs show "Invalid signature" errors

Causes & Solutions:

Using wrong secret: Verify you're using the correct webhook secret from GitHub settings. Test vs production environments often have different secrets.

Solution: Check your environment variables match the secret shown in GitHub webhook settings.

Parsing JSON before verification: Once you call JSON.parse() or use express.json(), the body is modified and signatures won't match.

Solution: Use express.raw() or equivalent raw body parser. Verify against the raw body, then parse JSON.

Missing sha256= prefix: GitHub's signature header includes sha256= before the hex digest. Your comparison must include this prefix.

Solution: Prepend sha256= to your computed signature before comparing.

Not using constant-time comparison: Regular equality (===) can leak timing information, but more importantly, can fail if the strings aren't exactly identical.

Solution: Always use crypto.timingSafeEqual(), hmac.compare_digest(), or hash_equals().

Encoding issues: Character encoding mismatches between signature computation and verification.

Solution: Ensure both use UTF-8 encoding consistently.

Debug steps:

// Log both signatures to compare
console.log('Received:', signature);
console.log('Expected:', expectedSignature);

// Verify secret is loaded
console.log('Secret loaded:', !!process.env.GITHUB_WEBHOOK_SECRET);

// Check raw body
console.log('Body length:', req.body.length);

Issue 2: Webhook Timeouts

Symptoms:

  • GitHub shows "Timeout" errors in Recent Deliveries
  • Webhooks marked as failed after 10 seconds
  • Your logs show webhook processing completed but GitHub still shows failure

Causes & Solutions:

Slow database queries: Blocking the HTTP response while querying databases.

Solution: Return 200 immediately, then process webhooks asynchronously using a queue.

External API calls: Waiting for third-party services (Slack notifications, CI/CD APIs) before responding.

Solution: Queue all external operations. Never wait for external APIs in your webhook endpoint.

Complex business logic: Performing heavy computations or complex workflows synchronously.

Solution: Move all business logic to async queue processors. The webhook endpoint should only verify signatures, check idempotency, and queue.

Example fix:

// Bad - blocks response
app.post('/webhooks/github', async (req, res) => {
  verifySignature(req);
  const payload = JSON.parse(req.body);

  await processWebhook(payload); // This might take > 10 seconds
  res.status(200).send('OK');
});

// Good - returns immediately
app.post('/webhooks/github', async (req, res) => {
  verifySignature(req);
  const payload = JSON.parse(req.body);

  await webhookQueue.add(payload); // Fast
  res.status(200).send('OK'); // Return immediately
});

Issue 3: Duplicate Events

Symptoms:

  • Same webhook processed multiple times
  • Duplicate deployments, notifications, or database records
  • Multiple log entries for the same delivery ID

Causes & Solutions:

No idempotency check: Not tracking which deliveries have been processed.

Solution: Store delivery IDs in Redis, database, or in-memory cache with TTL. Check before processing.

GitHub retries: GitHub may resend webhooks if your endpoint times out or returns an error.

Solution: Implement idempotent operations. Use delivery IDs to detect and skip duplicates.

Manual redelivery: Team members may manually redeliver webhooks for debugging.

Solution: Your idempotency check will handle this automatically.

Example implementation:

const deliveryId = req.headers['x-github-delivery'];

// Check if already processed
const exists = await redis.get(`github:${deliveryId}`);
if (exists) {
  console.log('Duplicate delivery, skipping');
  return res.status(200).json({ duplicate: true });
}

// Mark as processed (7 day TTL)
await redis.setex(`github:${deliveryId}`, 604800, 'true');

// Process webhook
await processWebhook(payload);

Issue 4: Missing Webhooks

Symptoms:

  • Expected webhooks never arrive
  • Recent Deliveries section is empty for events you think should fire
  • No errors shown, webhooks just don't appear

Causes & Solutions:

Wrong event selection: The event you're waiting for isn't selected in webhook configuration.

Solution: Edit your webhook in GitHub settings and verify the event type is checked.

Firewall blocking GitHub: Network firewall blocks incoming requests from GitHub's IP ranges.

Solution: Whitelist GitHub's webhook IP addresses if your infrastructure uses IP filtering.

URL typo: Webhook URL in GitHub settings doesn't match your actual endpoint.

Solution: Verify the exact URL in webhook settings, including protocol (https://), path, and any query parameters.

Event doesn't trigger what you expect: Some events only fire for specific actions (e.g., "synchronize" for PR updates).

Solution: Review GitHub's webhook event documentation to understand exactly when events fire.

SSL certificate issues: Invalid, expired, or self-signed SSL certificates.

Solution: Ensure your endpoint has a valid SSL certificate from a trusted CA. Use Let's Encrypt for free certificates.

Endpoint not running: Your webhook server crashed or wasn't started.

Solution: Verify your server is running and accessible at the configured URL. Use curl to test externally.

Issue 5: Rate Limiting and Performance

Symptoms:

  • Slow webhook processing
  • Growing queue of unprocessed webhooks
  • High CPU or memory usage

Causes & Solutions:

Processing too many events: Subscribed to "Send me everything" with high-traffic repositories.

Solution: Subscribe only to events you need. Uncheck unused events in webhook settings.

Inefficient processing: N+1 queries, unnecessary API calls, or unoptimized code.

Solution: Profile your webhook processing code. Batch database operations, cache frequently accessed data.

Queue overflow: Webhooks arriving faster than you can process them.

Solution: Scale horizontally (more worker processes), optimize processing speed, or increase queue workers.

Debugging Checklist

When troubleshooting GitHub webhook issues:

  • Check GitHub's Recent Deliveries section for error details
  • Verify webhook endpoint is publicly accessible (test with curl)
  • Test signature verification with known-good payload from GitHub
  • Check application logs for errors or unexpected behavior
  • Verify SSL certificate is valid (use SSL checker tools)
  • Test with our Webhook Payload Generator
  • Confirm no rate limiting or quota errors
  • Check queue depth if using async processing
  • Verify environment variables are loaded correctly
  • Test different event types to isolate issues

Frequently Asked Questions

Q: How often does GitHub send webhooks?

A: GitHub sends webhooks immediately when events occur, typically within seconds. Delivery speed depends on network latency and GitHub's server load. If delivery fails, GitHub does not implement automatic retries—you must manually redeliver from the Recent Deliveries section.

Q: Can I receive webhooks for past events?

A: No, GitHub webhooks only send notifications for events that occur after the webhook is configured. You cannot retroactively receive webhooks for historical events. To get historical data, use the GitHub REST or GraphQL APIs to fetch past commits, pull requests, issues, etc.

Q: What happens if my endpoint is down?

A: GitHub attempts to deliver each webhook once. If delivery fails (connection refused, timeout, non-2xx status code), the delivery is marked as failed in the Recent Deliveries section. You can manually redeliver failed webhooks by clicking "Redeliver" next to any delivery. Consider implementing a reconciliation process using GitHub's API to catch events missed during downtime.

Q: Do I need different endpoints for test and production?

A: While you can use the same endpoint with different secrets, best practice is to use separate webhook URLs for development, staging, and production environments. This prevents test webhooks from affecting production systems and allows independent testing. Configure webhooks in test repositories pointing to staging environments.

Q: How do I handle webhook ordering?

A: GitHub does not guarantee webhook delivery order. Events may arrive out of sequence due to network conditions or parallel processing. Design your webhook handlers to be order-independent by using timestamp fields (like created_at, updated_at) to determine event sequence. Store event data with timestamps and handle reordering in your application logic.

Q: Can I filter which events I receive?

A: Yes, when configuring your webhook in GitHub settings, you can select specific event types under "Let me select individual events." Only subscribe to events your application needs to reduce bandwidth, processing overhead, and security exposure. You can modify event subscriptions anytime by editing the webhook.

Q: What's the maximum payload size for GitHub webhooks?

A: GitHub caps webhook payloads at 25 MB. If an event generates a larger payload (extremely rare, typically only with massive commits), GitHub will not deliver the webhook. For such cases, you'll need to use the GitHub API to fetch the data directly. Most webhooks are under 10 KB.

Q: How long are webhook delivery records retained?

A: GitHub retains webhook delivery history in the Recent Deliveries section for approximately 30 days. After that, delivery records are removed. For long-term auditing, log all received webhooks in your application with delivery IDs, timestamps, and processing results.

Q: Can I secure my webhook with IP whitelisting?

A: Yes, GitHub publishes their webhook source IP addresses via their Meta API endpoint (https://api.github.com/meta). Look for the "hooks" array. However, these IPs can change, so monitor the Meta API for updates. Alternatively, rely solely on signature verification, which provides strong security without IP dependencies.

Q: How do I handle GitHub webhook deprecations?

A: GitHub announces API and webhook changes in their changelog and developer blog. Subscribe to the GitHub Changelog (https://github.blog/changelog/) and monitor deprecation notices. Test your webhooks regularly, especially after GitHub announces changes. The X-Hub-Signature (SHA1) header is deprecated in favor of X-Hub-Signature-256 (SHA256)—ensure you're using the modern signature method.

Next Steps & Resources

Now that you understand GitHub webhooks, it's time to put your knowledge into practice.

Try It Yourself:

  1. Create a test repository on GitHub
  2. Set up a webhook following the steps in this guide
  3. Test locally using our Webhook Payload Generator
  4. Implement signature verification in your preferred language
  5. Deploy to production and monitor Recent Deliveries

Test Your Integration:

Use our Webhook Payload Generator to:

  • Generate properly signed GitHub webhook payloads
  • Test all event types (push, pull_request, release, etc.)
  • Validate your signature verification logic
  • Create edge case scenarios for robust testing

Additional Resources:

Related Guides:

Need Help?

Conclusion

GitHub webhooks provide a powerful, efficient way to integrate with repositories and automate CI/CD workflows. By sending real-time HTTP notifications for events like code pushes, pull requests, releases, and issue updates, webhooks eliminate the need for inefficient API polling and enable truly responsive integrations.

By following this guide, you now know how to:

  • Set up GitHub webhooks in repository or organization settings
  • Verify webhook signatures securely using HMAC-SHA256
  • Implement production-ready webhook endpoints with proper error handling
  • Handle common issues like timeouts, duplicate deliveries, and signature failures
  • Test webhooks effectively with ngrok or our Webhook Payload Generator tool

Remember the key principles for production-ready GitHub webhook implementations:

  1. Always verify signatures using HMAC-SHA256 for security
  2. Respond quickly within 10 seconds to avoid timeouts
  3. Process asynchronously using queues for reliability
  4. Implement idempotency using delivery IDs to handle duplicates
  5. Monitor continuously by checking Recent Deliveries and application logs

Start building with GitHub webhooks today, and use our Webhook Payload Generator to test your integration thoroughly before deploying to production. With proper implementation, GitHub webhooks will transform your development workflow, enabling real-time automation and seamless integrations.

Have questions or run into issues? Drop a comment below or check GitHub's comprehensive documentation for additional support.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.