All posts
githubwebhooksguideci-cd

GitHub Webhooks: A Complete Setup and Debugging Guide

How to receive, verify, and reliably process GitHub webhook events. Covers repository events, Actions, signature verification, and common failure patterns.

28 May 20267 min read

GitHub webhooks fire on practically every meaningful event in your repositories: pushes, pull requests, workflow runs, releases, issue comments, review approvals. If you are building a CI/CD integration, a developer tool, or any service that reacts to code activity, you will be dealing with GitHub webhooks.

This guide covers everything you need to set them up correctly.

What GitHub webhooks can do

GitHub sends webhook events for:

  • Repository events - push, create (branch/tag), delete, fork, star, watch
  • Pull request events - opened, closed, merged, review requested, review submitted
  • Issue events - opened, closed, commented, labelled, assigned
  • Workflow events - workflow_run, check_run, check_suite, deployment, deployment_status
  • Security events - secret_scanning_alert, code_scanning_alert, dependabot_alert
  • Releases - published, created, edited, deleted

Each event type can send multiple actions within it. A pull_request event can have an action of opened, closed, synchronize, labeled, and so on.

Setting up a webhook

Create your receiving endpoint

Create a public HTTPS endpoint. For local development, use a tunnel like ngrok or Cloudflare Tunnel to expose localhost.

ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

Configure the webhook in GitHub

Go to your repository, then Settings, then Webhooks, then Add webhook.

  • Payload URL: your endpoint URL
  • Content type: application/json (not application/x-www-form-urlencoded)
  • Secret: generate a long random string - you will need this for verification
  • Events: choose specific events or "Send me everything"

Implement and verify

Build your handler, verify the signature on every request, return 200 immediately.

Signature verification

GitHub signs every webhook with HMAC-SHA256 using your webhook secret. The signature is in the X-Hub-Signature-256 header, prefixed with sha256=. If you're new to this, our guide to webhook signature verification covers how HMAC signing works and the mistakes that break it.

Always verify signatures before processing. Without verification, anyone who knows your endpoint URL can fake GitHub events and trigger deployments, issue closures, or whatever your handler does.

import { createHmac, timingSafeEqual } from 'crypto';
import type { Request, Response } from 'express';

function verifyGitHubSignature(
  rawBody: Buffer,
  signature: string | undefined,
  secret: string
): boolean {
  if (!signature) return false;

  const hmac = createHmac('sha256', secret);
  hmac.update(rawBody);
  const expected = `sha256=${hmac.digest('hex')}`;

  try {
    return timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    // Lengths differ - definitely not a match
    return false;
  }
}

export function handleGitHubWebhook(req: Request, res: Response) {
  const signature = req.headers['x-hub-signature-256'] as string;
  const event = req.headers['x-github-event'] as string;
  const deliveryId = req.headers['x-github-delivery'] as string;

  if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET!)) {
    console.warn(`[webhook] Signature verification failed for delivery ${deliveryId}`);
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Acknowledge immediately
  res.status(200).json({ ok: true });

  // Process asynchronously
  void processGitHubEvent(event, req.body, deliveryId);
}

Use express.raw({ type: 'application/json' }) on your webhook route. If you use express.json() globally, the raw body bytes are lost before you can verify the signature.

Handling events

GitHub sends the event type in the X-GitHub-Event header, and the payload includes an action field for events that have multiple sub-types.

async function processGitHubEvent(
  event: string,
  payload: Record<string, unknown>,
  deliveryId: string
) {
  try {
    switch (event) {
      case 'push':
        await handlePush(payload);
        break;

      case 'pull_request': {
        const action = payload.action as string;
        if (action === 'opened' || action === 'synchronize') {
          await handlePRUpdated(payload);
        } else if (action === 'closed' && payload.pull_request?.merged) {
          await handlePRMerged(payload);
        }
        break;
      }

      case 'workflow_run': {
        const action = payload.action as string;
        if (action === 'completed') {
          await handleWorkflowCompleted(payload);
        }
        break;
      }

      case 'ping':
        // GitHub sends this when a webhook is first created - just acknowledge
        console.log('GitHub webhook ping received');
        break;

      default:
        console.log(`Unhandled GitHub event: ${event}`);
    }
  } catch (err) {
    console.error(`[webhook] Error processing ${event} (${deliveryId}):`, err);
  }
}

Key event payloads

push

Fired on every push to a branch or tag. The payload includes the commits, before/after SHAs, and the ref.

interface PushPayload {
  ref: string;           // e.g. "refs/heads/main"
  before: string;        // SHA before the push
  after: string;         // SHA after the push
  commits: Commit[];
  repository: Repository;
  pusher: { name: string; email: string };
}

To check which branch was pushed to:

const branch = payload.ref.replace('refs/heads/', '');
if (branch === 'main') {
  await triggerDeployment(payload.after);
}

pull_request

The most event-rich type. You will typically care about:

ActionWhen
openedNew PR created
synchronizeNew commits pushed to the PR branch
closed + merged: truePR merged
closed + merged: falsePR closed without merging
review_requestedReview requested from a user or team
labeled / unlabeledLabels changed

workflow_run

Fires when a GitHub Actions workflow run starts or completes. Useful for triggering downstream steps or sending notifications.

interface WorkflowRunPayload {
  action: 'requested' | 'in_progress' | 'completed';
  workflow_run: {
    id: number;
    name: string;
    status: 'queued' | 'in_progress' | 'completed';
    conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null;
    head_branch: string;
    head_sha: string;
    html_url: string;
  };
}

GitHub's delivery guarantees and retries

GitHub delivers webhooks with a 10-second timeout. If your endpoint does not respond within 10 seconds, GitHub considers it a failure.

GitHub’s documented delivery policy:

  • GitHub records a failed attempt and does not automatically redeliver it
  • Use manual redelivery or implement a recovery script through the REST API
  • You can manually redeliver failed webhooks from Settings, then Webhooks, then Recent Deliveries

10 seconds is tight. Return 200 immediately and process asynchronously. Any database writes, API calls, or CI triggers should happen in a background job, not inline.

Idempotency

Manual redelivery or a recovery script can deliver an event more than once. Use the X-GitHub-Delivery header as an idempotency key, the same approach we cover in how to stop processing duplicate webhooks:

async function processGitHubEvent(
  event: string,
  payload: Record<string, unknown>,
  deliveryId: string
) {
  // Check if already processed
  const seen = await redis.get(`gh:delivery:${deliveryId}`);
  if (seen) {
    console.log(`Skipping duplicate delivery ${deliveryId}`);
    return;
  }

  // Mark as seen (expire after 24h)
  await redis.set(`gh:delivery:${deliveryId}`, '1', 'EX', 86400);

  // Process...
}

Testing locally

The GitHub CLI makes it easy to forward real webhook traffic to your local machine:

# Install the webhook extension
gh extension install cli/gh-webhook

# Forward events to local server
gh webhook forward --repo=owner/repo --events=push,pull_request --url=http://localhost:3000/webhooks/github

Or manually from the GitHub UI: Settings, then Webhooks, select a recent delivery, then Redeliver.

Common failure patterns

These are the GitHub-specific ones. For the broader picture of why webhooks fail and how to catch each mode, we cover the full list separately.

"Signature verification failing in production but works locally"

Check that you are using the correct secret for each webhook configuration. If you have separate webhooks for dev, staging, and production, each has its own secret.

"Events arriving out of order"

Do not assume events arrive in chronological order. A pull_request.closed can arrive before pull_request.review_submitted. Design handlers to fetch current state from the GitHub API if ordering matters, rather than reconstructing it from the event sequence.

"Missing events after a deploy"

Events that fired during your deployment window may have retried and given up. Check the webhook delivery logs in GitHub under Settings, then Webhooks, then Recent Deliveries, and manually redeliver any that failed.

"Webhook fires but nothing happens"

Usually the 10-second timeout. Check if your handler is doing synchronous work that takes too long. Move processing to a background queue.


GitHub webhooks are reliable once the signature verification and async processing pattern is in place. The main thing to get right is the timeout. GitHub's 10-second window catches a lot of teams who handle everything synchronously.

For full visibility into every GitHub webhook delivery (what arrived, what you responded, whether retries happened) Webhook Watchtower sits in front of your endpoint and gives you that audit trail without any changes to your existing handler. The GitHub setup guide covers the full configuration, including repository vs organisation webhooks and how to set a signing secret.

Frequently asked questions

How do I set up a webhook in GitHub?

Open your repository's Settings, go to Webhooks, and click Add webhook. Set the payload URL to your endpoint, choose application/json as the content type, add a secret so you can verify requests, and select the events you want GitHub to send. You can also add webhooks at the organisation level to cover every repository at once.

How do I forward GitHub webhooks to another URL?

Point the GitHub webhook at a capture-and-forward service instead of directly at your app, then have that service relay each delivery to your real endpoint. This gives you a durable copy of every event, automatic retries if your endpoint is down, and one-click replay. Webhook Watchtower does exactly this: GitHub delivers to a Watchtower ingest URL, and Watchtower forwards to your destination.

Which GitHub webhook events should I listen for?

Only subscribe to the events you actually handle. Common ones are push, pull_request, and workflow_run for CI and automation. Subscribing to everything just means more traffic to verify and store, so start narrow and add events as you need them.

Why is my GitHub webhook not being received?

The usual causes are an endpoint that returns a non-2xx status, a signature check that rejects the request, a firewall blocking GitHub's IP ranges, or a handler that's too slow and times out. Check the Recent Deliveries tab on the webhook settings page: GitHub shows the exact request, response code, and body for every attempt.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →