How to Test GitHub Webhooks with HookCap
GitHub webhooks power most CI/CD pipelines, deployment automation, and DevOps tooling. When a push lands, a pull request opens, or a release publishes, GitHub fires a webhook to your endpoint. Building reliable handlers for these events requires seeing exactly what GitHub sends -- headers, payload structure, delivery IDs -- and being able to replay them without pushing dummy commits.
This guide covers how to use HookCap to capture, inspect, and replay GitHub webhook events during development.
GitHub Webhook Use Cases
GitHub webhooks are the trigger mechanism for nearly every automation in a developer workflow:
-
CI/CD pipelines --
pushandpull_requestevents kick off builds, tests, and deployments -
Issue and PR tracking --
issues,pull_request, andissue_commentevents sync to project management tools -
Deployment notifications --
deployment_statusevents update Slack, PagerDuty, or custom dashboards -
Release automation --
releaseevents trigger changelogs, artifact publishing, and notifications -
Repository mirroring --
pushevents sync code to secondary repositories
Every one of these integrations needs a publicly accessible URL during development. That's the core problem: GitHub can't reach localhost:3000.
Setting Up HookCap to Receive GitHub Webhooks
1. Create a HookCap endpoint
Sign in at hookcap.dev and create a new endpoint. You'll get a persistent URL:
https://hook.hookcap.dev/ep_a1b2c3d4e5f6
This URL is permanent -- it keeps receiving events across sessions, which is important because GitHub webhook configurations persist in your repository settings.
2. Add the webhook to your GitHub repository
Go to your repository → Settings → Webhooks → Add webhook.
Configure it as follows:
- Payload URL: paste your HookCap endpoint URL
-
Content type:
application/json - Secret: generate a random string and save it -- you'll need it for signature validation
- Events: choose "Send me everything" for broad capture, or select specific events
Click Add webhook. GitHub immediately sends a ping event to confirm delivery. You'll see it appear in HookCap within seconds.
3. Verify the ping event
In HookCap, open the endpoint and find the ping delivery. You'll see:
- The
X-GitHub-Event: pingheader - The
X-GitHub-DeliveryUUID that identifies this specific delivery - The
X-Hub-Signature-256HMAC header - The full JSON payload including your
hook_idand repository info
This confirms the connection is working before you write a single line of handler code.
Inspecting GitHub Event Payloads
GitHub's webhook payloads carry a lot of context. Here are the three most common events and what to look for in each.
Push events
Triggered on every commit push. The payload includes:
{
"ref": "refs/heads/main",
"before": "abc123...",
"after": "def456...",
"commits": [
{
"id": "def456...",
"message": "fix: handle null response from API",
"author": { "name": "Jane", "email": "jane@example.com" },
"added": [],
"modified": ["src/api/client.ts"],
"removed": []
}
],
"repository": { "full_name": "org/repo", "private": false },
"pusher": { "name": "jane" }
}
Key fields your handler likely needs: ref (to filter by branch), commits[].id (for checkout), and after (the HEAD SHA to build).
Pull request events
The pull_request event fires on opened, closed, merged, synchronize, and several other actions. The action field tells you which:
{
"action": "opened",
"number": 42,
"pull_request": {
"head": { "sha": "abc123", "ref": "feature/new-auth" },
"base": { "ref": "main" },
"state": "open",
"merged": false,
"title": "Add OAuth2 login"
},
"repository": { "full_name": "org/repo" }
}
A common mistake is triggering builds on all PR actions when you only care about opened and synchronize. Viewing captured payloads in HookCap makes this obvious -- you'll see the action field vary across deliveries.
Release events
Fires when a release is published, created, or edited. The published action is typically what triggers deployment or notification workflows:
{
"action": "published",
"release": {
"tag_name": "v2.1.0",
"name": "Release 2.1.0",
"draft": false,
"prerelease": false,
"assets": [...]
},
"repository": { "full_name": "org/repo" }
}
Using Replay to Test Webhook Handlers
Once you have real GitHub events captured in HookCap, you can replay them against your local server without triggering new GitHub activity.
Why replay matters
Testing GitHub webhook handlers typically requires pushing commits or opening PRs to generate real events. This creates noise in your repository history and is impractical when you need to test edge cases or error paths. With HookCap, you capture one real event and replay it as many times as needed.
Replay workflow
- In HookCap, find the delivery you want to replay
- Click Replay and enter your local server URL (e.g.,
http://localhost:3000/webhooks/github) - HookCap forwards the original payload with the original headers, including
X-Hub-Signature-256 - Your handler receives exactly what GitHub sent -- same headers, same body, same signature
This is particularly useful for:
- Testing your signature validation logic with a real HMAC-signed payload
- Reproducing a specific payload structure that triggered a bug
- Verifying a handler fix without generating new repository activity
GitHub Webhook Security: HMAC Signature Validation
GitHub signs every webhook payload with HMAC-SHA256 using the secret you configured. Your handler must verify this signature before processing any event.
How the signature works
GitHub computes HMAC-SHA256(secret, rawBody) and sends it in the X-Hub-Signature-256 header as sha256=<hex-digest>. Your handler needs the raw request body -- not parsed JSON -- to reproduce the same digest.
Verification in Node.js
import { createHmac, timingSafeEqual } from "crypto";
function verifyGitHubSignature(
rawBody: Buffer,
signature: string,
secret: string
): boolean {
const expected = "sha256=" + createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(
Buffer.from(expected, "utf8"),
Buffer.from(signature, "utf8")
);
}
// Express handler
app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-hub-signature-256"] as string;
if (!verifyGitHubSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = req.headers["x-github-event"] as string;
const payload = JSON.parse(req.body.toString());
// Route by event type
switch (event) {
case "push":
handlePush(payload);
break;
case "pull_request":
handlePullRequest(payload);
break;
case "release":
handleRelease(payload);
break;
}
res.status(200).send("OK");
});
Two common mistakes that fail verification:
- Parsing the body before verification. JSON parsers may reformat whitespace or reorder keys, changing the byte-level content. Always read the raw body for HMAC computation.
-
Using
===instead oftimingSafeEqual. String comparison short-circuits on the first differing character, leaking timing information. Use a constant-time comparison function.
Testing signature validation with HookCap
When you replay a GitHub event through HookCap to your local server, the X-Hub-Signature-256 header reflects the secret configured in your GitHub webhook settings. If your local handler uses the same secret, the signature will verify correctly.
If it doesn't verify, check: is your local GITHUB_WEBHOOK_SECRET environment variable set to the same value you used when registering the webhook in GitHub?
Common Problems You'll See in HookCap
Duplicate event handling. Some GitHub events fire multiple times for the same logical action. For example, pull_request fires on opened, then again on labeled if you add a label when creating the PR. If your handler is triggering builds twice, check the action field in your captured payloads.
Missing X-GitHub-Delivery deduplication. Each delivery has a unique X-GitHub-Delivery UUID. GitHub retries failed deliveries with the same UUID, which means you can see the same event multiple times if your handler returned a non-2xx status. HookCap's delivery log shows every attempt, making retry sequences easy to trace.
Branch filter gaps. A push event fires for every branch, including dependency update branches and feature branches. If your CI pipeline runs on every push, check your ref filtering logic against real payloads captured in HookCap.
Summary
HookCap gives you a persistent capture URL for GitHub webhooks, full payload visibility across event types, and replay against any target server. That combination cuts the feedback loop from "push a commit, wait for GitHub to deliver, check logs" to "replay a captured event, verify locally, iterate."
For teams building GitHub integrations, the key workflow is: configure HookCap as your webhook endpoint, capture real events across the scenarios you care about, then use replay to drive your handler testing without generating repository noise.
Get started at hookcap.dev.
Top comments (0)