What People Actually Use GitHub Webhooks For
GitHub webhooks are the connective tissue of most CI/CD setups. When you push to a branch, GitHub can instantly notify an external service — your build server, your deployment platform, your Slack channel — before you've even switched tabs. No polling, no cron jobs, no wondering whether the deploy kicked off. The push event fires, the webhook fires, and whatever needs to happen next happens.
The practical use cases break down roughly like this: triggering a test suite on every pull request, deploying to staging when something merges to main, posting a Slack message when a release is published, syncing GitHub issues to a project management tool like Linear or Jira, and notifying a security scanner every time new code is pushed. Any of these is a webhook.
The GitHub webhooks documentation covers the full picture, but the documentation is dense. This guide cuts straight to the patterns that come up in real projects.
Setting Up a Webhook in GitHub (Repository vs Organization)
There are two places you can create a GitHub webhook: at the repository level or at the organization level. A repository webhook only fires for events in that one repository. An organization webhook fires for events across all repositories in the org — useful when you want a single endpoint to handle events from dozens of repos without configuring each one individually.
For a repository webhook, go to your repo on GitHub, then Settings → Webhooks → Add webhook. For an organization webhook, go to your org page, then Settings → Webhooks → Add webhook. The configuration form is identical.
Payload URL
The URL GitHub will POST to. During development, use an ngrok tunnel URL. In production, this should be a dedicated endpoint on your server — something like https://yourapp.com/webhooks/github.
Content type — always use application/json
The default is application/x-www-form-urlencoded, which is a pain to work with. Change it to application/json. There's no good reason to use the form-encoded option in modern code.
Secret — set one, save it somewhere
Generate a random secret (something like openssl rand -hex 32) and paste it in. GitHub will use this to sign every request with an HMAC-SHA256 signature. Store it in an environment variable on your server — you'll need it for verification.
Events — pick what you actually need
"Send me everything" is tempting but means your server gets hit with events you don't care about. Subscribe to the specific event types you need. You can always add more later.
The Events You'll Actually Use
The full list of GitHub webhook events is long, but a handful cover the overwhelming majority of real-world use cases.
push
Fires whenever commits are pushed to any branch. The most commonly used event. Contains the branch name (ref), the list of commits, and who pushed. Used to trigger builds, deployments, and static analysis.
pull_request
Fires when a PR is opened, synchronized (new commits pushed to the branch), closed, or merged. The action field tells you which one. Used to trigger test runs, notify reviewers, or kick off a staging deploy.
release
Fires when a release is published, updated, or deleted. The published action is the most useful — this is when you'd trigger a production deployment or post a release announcement to Slack.
workflow_run
Fires when a GitHub Actions workflow run completes. Useful if you want an external system to know when your CI pipeline finished — and whether it passed or failed — without that system needing to poll the GitHub API.
issues
Fires when an issue is opened, edited, closed, labeled, assigned, and more. If you're syncing GitHub issues to an external project management tool, this is your event. The payload includes the full issue object with all labels, assignees, and body text.
What a Push Event Actually Looks Like
The push event payload is rich, but there are four fields you'll reach for every time:
{
"ref": "refs/heads/main",
"commits": [
{
"id": "abc123def456",
"message": "Fix the thing",
"author": { "name": "Jane Dev", "email": "[email protected]" },
"url": "https://github.com/org/repo/commit/abc123def456"
}
],
"repository": {
"name": "my-repo",
"full_name": "org/my-repo",
"clone_url": "https://github.com/org/my-repo.git"
},
"pusher": {
"name": "janedev",
"email": "[email protected]"
},
"head_commit": {
"id": "abc123def456",
"message": "Fix the thing"
}
}The ref field tells you which branch was pushed to — it's always in the format refs/heads/branch-name. To get just the branch name you'll typically do ref.replace('refs/heads/', '').
Real GitHub push payloads are much larger — they include the full diff statistics, commit tree information, and more. When you're first wiring up a handler, it's useful to dump the full payload and explore it properly. JsonFormatter.ai is good for this — paste in the raw payload from the GitHub delivery log and you get a nicely formatted tree you can actually navigate.
Verifying the Signature (Don't Skip This)
GitHub signs every webhook delivery with HMAC-SHA256 using the secret you configured. The signature arrives in the X-Hub-Signature-256 header with a sha256= prefix. The full validation process is documented in GitHub's signature validation guide.
Your webhook URL is public. Anyone who knows it can POST to it. Signature verification is how you ensure the request actually came from GitHub and wasn't crafted by someone trying to trigger your CI pipeline or your deployment. Here's how to do it in Node.js:
import crypto from 'crypto';
function verifyGitHubSignature(rawBody, signature, secret) {
const expectedSignature =
'sha256=' +
crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be a Buffer or string of raw bytes
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (sigBuffer.length !== expectedBuffer.length) return false;
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
// In your Express handler:
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const isValid = verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
const eventType = req.headers['x-github-event'];
// Now handle the event...
res.status(200).send('OK');
});Two things to notice: the body parser must preserve the raw bytes (use express.raw(), not express.json()), and the comparison uses crypto.timingSafeEqual rather than ===. The constant-time comparison prevents timing attacks where an attacker can infer the correct signature by measuring how long the comparison takes.
A Complete CI Trigger: Push to Main, Run a Build
Here's what a real push-triggered deployment handler looks like end to end. GitHub pushes to your endpoint, you verify the signature, check that it's the right branch, and kick off a build.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), async (req, res) => {
// Always acknowledge quickly
res.status(200).send('OK');
try {
// Verify signature
const signature = req.headers['x-hub-signature-256'];
if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
console.warn('Invalid GitHub signature — ignoring request');
return;
}
const eventType = req.headers['x-github-event'];
if (eventType !== 'push') return; // Only handle push events
const payload = JSON.parse(req.body.toString());
const branch = payload.ref.replace('refs/heads/', '');
// Only deploy on pushes to main
if (branch !== 'main') {
console.log(`Push to ${branch} — skipping deploy`);
return;
}
const commitSha = payload.head_commit.id;
console.log(`Push to main by ${payload.pusher.name}, commit ${commitSha} — triggering build`);
await triggerBuild({ branch, commitSha, repo: payload.repository.full_name });
} catch (err) {
console.error('GitHub webhook handler error:', err);
}
});The res.status(200).send('OK') goes at the top, before the async work. GitHub expects a response within 10 seconds and will mark the delivery as failed if it times out. Sending the response immediately and then doing the work asynchronously is the right pattern for anything that might take more than a second or two.
Handling pull_request Events Without Losing Your Mind
The pull_request event is fired for a lot of different things: when a PR is opened, when new commits are pushed to the branch (synchronize), when it's closed, when it's converted to a draft, when a label is added — the list is longer than you'd expect. The action field in the payload is how you tell them apart.
If you want to trigger something on new PRs and on new commits pushed to existing PRs, you care about action === 'opened' and action === 'synchronize'. For a staging deploy on merge specifically, you need to check for both action === 'closed' and payload.pull_request.merged === true. A closed PR that wasn't merged will also have action === 'closed' — you don't want to deploy that.
const eventType = req.headers['x-github-event'];
if (eventType !== 'pull_request') return;
const { action, pull_request: pr } = payload;
if (action === 'opened' || action === 'synchronize') {
// New PR or new commits on existing PR — run tests
await triggerTestRun({ branch: pr.head.ref, sha: pr.head.sha, prNumber: pr.number });
}
if (action === 'closed' && pr.merged === true) {
// PR was merged (not just closed) — deploy to staging
const targetBranch = pr.base.ref; // what branch was merged INTO
if (targetBranch === 'main') {
await deployToStaging({ sha: pr.merge_commit_sha });
}
}Notice pr.base.ref — that's the branch the PR was targeting. If your repo has multiple long-lived branches (main, staging, etc.), you'll want to check this before triggering a deploy.
Organization Webhooks and When to Use Them
Organization webhooks are scoped to the entire org — they receive events from every repository in the organization. The setup is the same as a repo webhook, but you need organization admin permissions to create one.
Use an org webhook when you have a single endpoint that handles events from many repositories — a centralized audit log, a deployment platform, a security scanner. The payload for org-level webhooks includes a repository field with the full name of the repo that triggered the event, so you can route accordingly.
Organization webhooks also support a few org-specific events that repo webhooks don't, like member (when someone is added or removed from the org) and organization (org settings changes). You can also create and manage webhooks programmatically using the GitHub REST API for webhooks — useful for tooling that provisions repos and needs to wire up webhooks automatically.
Token permissions for the REST API
Creating or updating repository webhooks via the API requires the write:repo_hook scope on your personal access token, or the admin:org_hook scope for org webhooks. Fine-grained personal access tokens need the "Webhooks" repository permission set to "Read and write".
Mistakes That Will Cost You an Afternoon
Two mistakes come up constantly, and both are worth calling out explicitly rather than discovering them the hard way.
The first is not filtering by branch in push event handlers. When you subscribe to the push event, GitHub sends it for pushes to any branch — feature branches, release branches, hotfix branches, all of them. If you deploy on every push event without checking ref, you'll deploy every time anyone pushes a commit anywhere in the repository. The fix is one line: if (branch !== 'main') return;. It's easy to miss when you're first testing on a single-person repo where all pushes happen to be to main.
The second is not reading the action field on pull_request events. If you trigger a staging deploy every time a pull_request webhook fires, you'll deploy when someone adds a label to a PR, when someone requests a review, when someone edits the PR description. All of those fire the same pull_request event with different action values. Always check the action.
Testing during development
Use ngrok to expose your local server. After setting up the tunnel, register the ngrok URL as the webhook payload URL in GitHub Settings. GitHub's webhook delivery log shows every request with the full payload and your server's response — use the "Redeliver" button to replay past events without making actual code changes on GitHub. It saves a lot of time.