GitHub is the source of truth for most engineering teams. Pull request merged? Trigger a deploy. Issue labeled? Notify Slack. Action failed? Page the on-call engineer. All of this runs on GitHub webhooks — and when they break, your entire automation pipeline goes silent.
This guide covers everything you need to integrate GitHub webhooks correctly: signature verification, event filtering, delivery guarantees, and how to handle the volume spikes that happen during large push events or mass PR operations.
GitHub Webhook Basics
GitHub webhooks send a POST request to your endpoint when specific events occur. The payload is JSON, and GitHub includes several important headers:
| Header | Value |
|---|---|
X-GitHub-Event | Event type (e.g., push, pull_request) |
X-GitHub-Delivery | UUID identifying this specific delivery |
X-Hub-Signature-256 | HMAC-SHA256 signature: sha256=<hex> |
Content-Type | application/json |
The X-GitHub-Delivery header is your idempotency key. Save it. If GitHub retries a delivery, this header stays the same — it's how you detect duplicates.
Setting Up a GitHub Webhook
In your GitHub repository: Settings → Webhooks → Add webhook.
Key decisions:
Payload URL: Where GitHub sends events. This must be publicly accessible. During local development, use GetHook's ingest URL or a tunnel like ngrok.
Content type: Always choose application/json. The application/x-www-form-urlencoded option wraps your payload in a payload= key — unnecessary complexity.
Secret: Generate a strong random value (32+ bytes). This is your HMAC signing key. Store it securely — you'll need it for signature verification.
Which events: Choose "Let me select individual events" rather than "Send me everything." This reduces noise and prevents your endpoint from getting hammered when events you don't care about spike.
Signature Verification
Every webhook from GitHub includes an X-Hub-Signature-256 header. Always verify it. Skipping this means anyone who knows your endpoint URL can send fake events.
GitHub signs the raw request body with HMAC-SHA256 using your webhook secret:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
)
func verifyGitHubSignature(r *http.Request, body []byte, secret string) bool {
signature := r.Header.Get("X-Hub-Signature-256")
if signature == "" {
return false
}
// Signature format: "sha256=<hex>"
if len(signature) < 7 || signature[:7] != "sha256=" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison — never use == for HMAC values
return hmac.Equal([]byte(expected), []byte(signature))
}Two things to get right:
- ›Read the raw body before parsing JSON. Once you call
json.Decode, you lose the raw bytes. Useio.ReadAllfirst, then unmarshal. - ›Use
hmac.Equal, not==. String comparison leaks timing information that an attacker can exploit to forge signatures.
Event Types Worth Handling
GitHub supports 40+ event types. Here are the ones most commonly needed in production:
Push Events
Fired on every push to any branch. The payload includes the commits, the ref (refs/heads/main), and the before/after SHAs.
{
"ref": "refs/heads/main",
"before": "abc123",
"after": "def456",
"commits": [
{
"id": "def456",
"message": "Fix authentication bug",
"author": { "name": "Jordan", "email": "jordan@example.com" }
}
],
"repository": { "full_name": "acme/api" }
}Volume warning: If you push a branch with 100 commits (force-push scenario), you get one push event but with 100 commits in the array. The payload can be large.
Pull Request Events
Covers opened, closed, merged, labeled, review_requested, and a dozen other action values. Filter by action in your handler — most integrations only care about opened, closed (where merged == true), and synchronize (new commits pushed to the PR).
type PRPayload struct {
Action string `json:"action"`
PullRequest struct {
Number int `json:"number"`
Merged bool `json:"merged"`
State string `json:"state"`
Head struct {
SHA string `json:"sha"`
Ref string `json:"ref"`
} `json:"head"`
} `json:"pull_request"`
}Workflow Run Events (GitHub Actions)
Fired when a workflow run starts, completes, or is requested. Useful for deploy tracking, Slack notifications, and metrics dashboards.
{
"action": "completed",
"workflow_run": {
"name": "CI",
"status": "completed",
"conclusion": "success",
"html_url": "https://github.com/acme/api/actions/runs/1234"
}
}Check Suite / Check Run Events
Lower-level than workflow runs — these are the individual check results. Useful if you're building a custom status dashboard or integrating with third-party CI systems.
Handling Event Spikes
GitHub sends webhooks synchronously from their side. If you have a mono-repo with 500 developers and someone merges a large PR, you might receive dozens of events in rapid succession.
The naive pattern breaks:
GitHub → POST /webhooks/github → your handler → sync DB write → 200 OKIf your DB is slow or the payload is large, you'll timeout. GitHub has a 10-second timeout. Exceed it and the delivery fails; GitHub will retry at 1, 5, 10, 30, and 60 minutes.
The correct pattern:
GitHub → POST /webhooks/github → enqueue(payload) → 200 OK (immediately)
worker reads queue → processes event → acknowledgesReturn 200 within 200ms. Always. Do the work asynchronously.
Idempotency with X-GitHub-Delivery
GitHub retries failed deliveries. Your handler must be idempotent.
func handleGitHub(w http.ResponseWriter, r *http.Request) {
deliveryID := r.Header.Get("X-GitHub-Delivery")
if deliveryID == "" {
http.Error(w, "missing delivery id", 400)
return
}
// Check if already processed
if processed, _ := cache.Get("gh:" + deliveryID); processed != nil {
w.WriteHeader(200) // Acknowledge duplicate without re-processing
return
}
body, _ := io.ReadAll(r.Body)
if !verifyGitHubSignature(r, body, webhookSecret) {
http.Error(w, "invalid signature", 401)
return
}
// Enqueue for async processing
queue.Push(QueueItem{
DeliveryID: deliveryID,
Event: r.Header.Get("X-GitHub-Event"),
Body: body,
})
w.WriteHeader(200)
}Store processed X-GitHub-Delivery values in Redis or your database with a TTL of 7 days (GitHub's max retry window).
Testing Locally
GitHub can't reach localhost:8080. Your options:
Option 1: GetHook ingest URL
Create a source in GetHook, point the GitHub webhook at your ingest URL, and configure a route to your local service. GetHook handles delivery, retry, and replay — you can replay failed events without re-triggering GitHub.
Option 2: Use GitHub's webhook delivery log
GitHub stores the last 3 months of webhook deliveries in Settings → Webhooks → Recent Deliveries. You can redeliver any event manually from this UI. Useful for debugging specific payloads.
Option 3: GitHub CLI with smee
npm install -g smee-client
smee --url https://smee.io/your-channel --target http://localhost:8080/webhooks/githubSmee proxies GitHub webhooks to your local port. Note: this sends real events, so be careful about triggering side effects in development.
Security Hardening Checklist
Before going to production with GitHub webhooks:
- › Verify
X-Hub-Signature-256on every request — reject unsigned payloads - › Use
hmac.Equal(constant-time) for signature comparison - › Read raw body before JSON parsing
- › Return 200 before processing (queue the work)
- › Store
X-GitHub-Deliveryfor idempotency - › Filter by
X-GitHub-Event— only process events you registered for - › Validate
repository.full_name— prevent event injection from forks - › Rotate webhook secret periodically and update both GitHub and your secret store
- › Set up alerts for sustained delivery failures in the GitHub webhook delivery log
Using GetHook for GitHub Webhooks
Rather than building all of the above yourself, point your GitHub webhook at a GetHook source. GetHook provides:
- ›Signature verification — configured per-source with GitHub's HMAC format
- ›Durable persistence — events stored before forwarding
- ›Retry logic — exponential backoff if your service is temporarily down
- ›Replay — re-send any historical event without touching GitHub
- ›Fan-out routing — send
pushevents to deploy pipeline andpull_requestevents to Slack simultaneously
Your backend receives pre-verified, durably-queued events. You handle the logic; GetHook handles the delivery reliability.