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.
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(notapplication/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:
| Action | When |
|---|---|
opened | New PR created |
synchronize | New commits pushed to the PR branch |
closed + merged: true | PR merged |
closed + merged: false | PR closed without merging |
review_requested | Review requested from a user or team |
labeled / unlabeled | Labels 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 →Related posts
Monitoring Webhook Health: The Metrics That Actually Matter
Uptime doesn't tell you whether webhooks are working. A short list of the signals that actually indicate a healthy webhook pipeline, and what each one catches that the others miss.
Alerting on Webhook Failures Without Alert Fatigue
Notify on every failed delivery attempt and you'll train people to ignore the channel. A pattern for alerting on what actually needs a human, and staying quiet about what doesn't.
IP Allowlisting vs Signature Verification: What Actually Secures a Webhook Endpoint
IP allowlisting feels like a solid first line of defence for a webhook endpoint. It isn't, and understanding why makes the case for signature verification much clearer.