GitHub Webhook Testing: Local Development Guide
GitHub Webhook Testing: Local Development Guide
GitHub webhooks drive a lot of automation. CI/CD pipelines, deployment triggers, Slack notifications, issue trackers, code review bots — they all depend on GitHub sending the right HTTP request to the right URL at the right time.
Testing them locally is awkward. Unlike Stripe, GitHub doesn't have a dedicated CLI for webhook forwarding. You're left stitching together tools on your own.
What GitHub Sends
When you configure a webhook on a repository (Settings → Webhooks → Add webhook), GitHub sends a POST request every time a matching event occurs.
The payload varies by event, but the structure is consistent:
{
"action": "opened",
"pull_request": {
"number": 42,
"title": "Fix login timeout bug",
"user": {
"login": "octocat"
},
"head": {
"ref": "fix/login-timeout",
"sha": "abc123def456"
},
"base": {
"ref": "main"
}
},
"repository": {
"full_name": "your-org/your-repo"
}
}
Headers include:
| Header | Purpose |
|---|---|
X-GitHub-Event |
Event type (push, pull_request, issues, etc.) |
X-Hub-Signature-256 |
HMAC-SHA256 signature for verification |
X-GitHub-Delivery |
Unique delivery ID (UUID) |
Content-Type |
Always application/json (if you configured JSON) |
The X-GitHub-Event header is how you route different events to different handlers. Don't parse the body to figure out what type of event it is — the header tells you.
Setting Up a Test Webhook
Go to your repository → Settings → Webhooks → Add webhook.
For local development you need a publicly accessible URL. Your options:
ngrok or similar tunnel:
ngrok http 3000
Take the https://xxxx.ngrok-free.app URL and paste it as the webhook URL. Problem: it changes every restart on the free plan, so you'll be editing this setting constantly.
Webhook capture service: Use a service like ThunderHooks to get a permanent URL. Configure it once in GitHub and never touch it again. When you need to test, inspect the captured payload in your dashboard and replay it to your local server.
GitHub's own redeliver button: After a webhook fires, go to Settings → Webhooks → Recent Deliveries. You can see the exact payload and redeliver it. Useful for debugging but requires the event to have already happened.
Choosing Events
GitHub lets you pick which events trigger the webhook. "Send me everything" is tempting but noisy. Start with what you actually need:
For a CI/CD bot: push, pull_request, check_run
For a deployment trigger: push (filtered to main branch in your handler)
For an issue tracker integration: issues, issue_comment
For a code review bot: pull_request, pull_request_review
You can always add more events later.
Handling Push Events
The most common webhook. Fires on every git push.
func handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
eventType := r.Header.Get("X-GitHub-Event")
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !verifySignature(payload, r.Header.Get("X-Hub-Signature-256")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
switch eventType {
case "push":
var push PushEvent
json.Unmarshal(payload, &push)
handlePush(push)
case "pull_request":
var pr PullRequestEvent
json.Unmarshal(payload, &pr)
handlePullRequest(pr)
case "ping":
// GitHub sends this when you first configure a webhook
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
}
The ping event catches people off guard. GitHub sends it immediately when you create or update a webhook to verify the URL works. If your handler doesn't recognize it and returns a 500, GitHub marks the webhook as failing before any real events arrive.
Signature Verification
GitHub signs every webhook delivery with HMAC-SHA256 using the secret you configured. Verification looks like this:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verifySignature(payload []byte, signatureHeader string) bool {
if signatureHeader == "" {
return false
}
// Header format: "sha256=<hex digest>"
parts := strings.SplitN(signatureHeader, "=", 2)
if len(parts) != 2 || parts[0] != "sha256" {
return false
}
mac := hmac.New(sha256.New, []byte(os.Getenv("GITHUB_WEBHOOK_SECRET")))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts[1]))
}
Python:
import hmac
import hashlib
def verify_signature(payload_body, signature_header, secret):
if not signature_header:
return False
expected = 'sha256=' + hmac.new(
secret.encode(),
payload_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
Node.js:
const crypto = require('crypto');
function verifySignature(payload, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
A few things to watch:
- Use constant-time comparison (
hmac.Equalin Go,timingSafeEqualin Node,compare_digestin Python). Regular string comparison is vulnerable to timing attacks. - The signature is computed against the raw request body. If your framework parses JSON before your handler runs, you'll need to access the original bytes. Same gotcha as Stripe.
- The header is
X-Hub-Signature-256. There's also an olderX-Hub-Signature(SHA-1) header that GitHub still sends for backwards compatibility. Use the SHA-256 one.
Testing Without Real Events
You don't always want to push commits or open PRs just to trigger a webhook. Here are your options.
GitHub API: Create a Webhook Delivery
The GitHub REST API lets you ping a webhook:
# Trigger a ping event
gh api repos/your-org/your-repo/hooks/HOOK_ID/pings -X POST
That only sends a ping though, not a real event.
Manually POST a Payload
Grab a real payload from GitHub's webhook delivery history (Settings → Webhooks → Recent Deliveries → click a delivery → copy the payload).
Then send it to your local server:
# Calculate the signature
SECRET="your-webhook-secret"
PAYLOAD='{"action":"opened","pull_request":{...}}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-H "X-GitHub-Delivery: $(uuidgen)" \
-d "$PAYLOAD"
This is tedious but it gives you full control. You can modify the payload to test edge cases — a PR with a very long title, a push with 500 commits, a deleted branch.
Replay From a Capture Service
This is where webhook capture shines. Configure GitHub to send webhooks to your ThunderHooks URL. Go about your normal development — push commits, open PRs, merge branches. All those webhooks are captured.
When you need to test your handler:
- Open ThunderHooks, find the webhook you want
- See the exact payload, headers, timing
- Start a tunnel to your localhost
- Hit replay
No manual curl commands. No calculating signatures. The original headers (including the signature) are preserved.
Common GitHub Webhook Gotchas
The 10-second timeout. GitHub expects a response within 10 seconds. That's shorter than most providers. If your handler does anything slow — database writes, API calls to other services, building Docker images — return 200 immediately and process asynchronously.
func handlePush(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
// Return immediately
w.WriteHeader(http.StatusOK)
// Process in background
go func() {
processPushEvent(payload)
}()
}
Organization webhooks vs. repository webhooks. Organization webhooks fire for events across all repos in the org. If you set up both, you'll get duplicate deliveries. Pick one.
Branch filtering doesn't exist at the webhook level. GitHub sends push events for every branch. If you only want main branch pushes, filter in your handler:
@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
event = request.get_json()
if request.headers.get('X-GitHub-Event') == 'push':
if event['ref'] != 'refs/heads/main':
return '', 200 # Ignore non-main pushes
process_event(event)
return '', 200
Deleted branches send a push event too. The after field will be all zeros (0000000000000000000000000000000000000000). If your handler tries to check out that commit, it'll fail. Check for this:
if (pushEvent.after === '0000000000000000000000000000000000000000') {
console.log(`Branch ${pushEvent.ref} was deleted`);
return;
}
GitHub Actions workflows can trigger webhooks. If your webhook handler triggers a GitHub Action (say, by pushing a tag), and that Action triggers another webhook, you've got an infinite loop. GitHub has some built-in protections against this but it's worth being aware of.
Testing Checklist
Before deploying a GitHub webhook handler:
- Handler responds to
pingevents with 200 - Signature verification using
X-Hub-Signature-256 - Response time under 10 seconds
- Branch filtering in handler (if needed)
- Handles deleted branch push events (zeroed SHA)
- Idempotent — duplicate deliveries don't cause problems
- Async processing for anything slow
- Logging includes
X-GitHub-DeliveryID for debugging