GitHub Webhooks
Route GitHub webhook events — issues, workflow runs, build failures, discussions, security alerts — to Slack, Discord, PagerDuty, or any other channel.
GitHub can send webhook events for nearly everything that happens in a repository or organization — new issues, failing builds, security alerts, discussions, deployments. Point them at a small proxy that transforms the payload into Alphorn's format, and route from there to any channel.
For PR, push, and release events see also Git & Repository Activity.
Setting up the webhook in GitHub
In your repo or org: Settings → Webhooks → Add webhook.
- Payload URL: the proxy endpoint you control (e.g.
https://proxy.example.com/github) - Content type:
application/json - Secret: a random string — used to verify the payload signature
- Events: select the events you care about (or "Send me everything")
The event type arrives in the X-GitHub-Event header; the action (e.g. opened, closed, completed) lives inside the JSON body.
Proxy with signature verification (Node.js)
This single proxy handles multiple event types and verifies the GitHub webhook signature before forwarding anything to Alphorn.
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }));
const ALPHORN_WEBHOOK = "https://app.alphorn.dev/api/webhooks/wh_abc123";
const GITHUB_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
function verifySignature(req) {
const sig = req.headers["x-hub-signature-256"];
if (!sig) return false;
const hmac = crypto.createHmac("sha256", GITHUB_SECRET);
const digest = "sha256=" + hmac.update(req.rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(digest));
}
app.post("/github", async (req, res) => {
if (!verifySignature(req)) return res.sendStatus(401);
const event = req.headers["x-github-event"];
const p = req.body;
const notification = buildNotification(event, p);
if (!notification) return res.sendStatus(200); // Ignored event
await fetch(ALPHORN_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(notification),
});
res.sendStatus(200);
});
function buildNotification(event, p) {
const repo = p.repository?.full_name;
switch (event) {
case "issues":
if (p.action === "opened") {
return {
title: `Issue opened: ${p.issue.title}`,
message: `${p.issue.user.login} opened #${p.issue.number} in ${repo}\n${p.issue.html_url}`,
priority: 3,
tags: ["github", "issue", "opened", repo],
};
}
if (p.action === "closed") {
return {
title: `Issue closed: ${p.issue.title}`,
message: `#${p.issue.number} in ${repo} closed by ${p.sender.login}`,
priority: 2,
tags: ["github", "issue", "closed", repo],
};
}
return null;
case "issue_comment":
if (p.action === "created") {
return {
title: `Comment on #${p.issue.number}: ${p.issue.title}`,
message: `${p.comment.user.login} in ${repo}:\n${p.comment.body.slice(0, 300)}`,
priority: 2,
tags: ["github", "issue", "comment", repo],
};
}
return null;
case "workflow_run":
if (p.action !== "completed") return null;
const run = p.workflow_run;
if (run.conclusion === "success") {
return {
title: `Workflow passed: ${run.name}`,
message: `${repo} on ${run.head_branch}\n${run.html_url}`,
priority: 2,
tags: ["github", "ci", "success", repo],
};
}
if (run.conclusion === "failure") {
return {
title: `Workflow FAILED: ${run.name}`,
message: `${repo} on ${run.head_branch} by ${run.actor.login}\n${run.html_url}`,
priority: 5,
tags: ["github", "ci", "failure", repo],
};
}
return null;
case "check_run":
if (p.action === "completed" && p.check_run.conclusion === "failure") {
return {
title: `Check failed: ${p.check_run.name}`,
message: `${repo} — ${p.check_run.output?.summary || "No summary"}\n${p.check_run.html_url}`,
priority: 4,
tags: ["github", "check", "failure", repo],
};
}
return null;
case "deployment_status":
const state = p.deployment_status.state;
if (state === "success" || state === "failure") {
return {
title: `Deployment ${state}: ${p.deployment.environment}`,
message: `${repo} — ${p.deployment.description || p.deployment.ref}`,
priority: state === "failure" ? 5 : 2,
tags: ["github", "deployment", state, p.deployment.environment],
};
}
return null;
case "discussion":
if (p.action === "created") {
return {
title: `Discussion: ${p.discussion.title}`,
message: `${p.discussion.user.login} in ${repo} (${p.discussion.category.name})\n${p.discussion.html_url}`,
priority: 2,
tags: ["github", "discussion", repo],
};
}
return null;
case "star":
if (p.action === "created") {
return {
title: `New star: ${repo}`,
message: `${p.sender.login} starred the repo (${p.repository.stargazers_count} total)`,
priority: 1,
tags: ["github", "star", repo],
};
}
return null;
case "fork":
return {
title: `Fork: ${repo}`,
message: `${p.forkee.owner.login} forked to ${p.forkee.full_name}`,
priority: 1,
tags: ["github", "fork", repo],
};
case "dependabot_alert":
if (p.action === "created") {
const a = p.alert;
return {
title: `Dependabot alert: ${a.security_advisory.summary}`,
message: `${repo} — ${a.dependency.package.name} (${a.security_advisory.severity})\n${a.html_url}`,
priority: a.security_advisory.severity === "critical" ? 5 : 4,
tags: ["github", "security", "dependabot", a.security_advisory.severity],
};
}
return null;
case "secret_scanning_alert":
if (p.action === "created") {
return {
title: `Secret detected in ${repo}`,
message: `Secret type: ${p.alert.secret_type_display_name}\n${p.alert.html_url}`,
priority: 5,
tags: ["github", "security", "secret-scanning"],
};
}
return null;
default:
return null;
}
}
app.listen(8080);Minimal Python proxy (Flask)
If you'd rather run a tiny Python service:
import hmac, hashlib, os, requests
from flask import Flask, request, abort
app = Flask(__name__)
ALPHORN = "https://app.alphorn.dev/api/webhooks/wh_abc123"
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
def verify(req):
sig = req.headers.get("X-Hub-Signature-256", "")
mac = hmac.new(SECRET, req.get_data(), hashlib.sha256)
return hmac.compare_digest(sig, "sha256=" + mac.hexdigest())
@app.post("/github")
def github():
if not verify(request):
abort(401)
event = request.headers.get("X-GitHub-Event")
p = request.get_json()
if event == "issues" and p["action"] == "opened":
payload = {
"title": f"Issue opened: {p['issue']['title']}",
"message": f"{p['issue']['user']['login']} in {p['repository']['full_name']}\n{p['issue']['html_url']}",
"priority": 3,
"tags": ["github", "issue", "opened"],
}
elif event == "workflow_run" and p["action"] == "completed" and p["workflow_run"]["conclusion"] == "failure":
run = p["workflow_run"]
payload = {
"title": f"Workflow FAILED: {run['name']}",
"message": f"{p['repository']['full_name']} on {run['head_branch']}\n{run['html_url']}",
"priority": 5,
"tags": ["github", "ci", "failure"],
}
else:
return "", 200
requests.post(ALPHORN, json=payload)
return "", 200Routing examples
| Channel | Filter | Purpose |
|---|---|---|
| PagerDuty | tags CONTAINS "ci" AND tags CONTAINS "failure" | Page on-call for build failures on main |
| Slack (#security) | tags CONTAINS "security" | Dependabot and secret scanning alerts |
| Slack (#support) | tags CONTAINS "issue" AND tags CONTAINS "opened" | Triage newly filed issues |
| Discord | tags CONTAINS "star" OR tags CONTAINS "fork" | Growth channel — low priority |
tags CONTAINS "deployment" AND tags CONTAINS "failure" | Deployment failure archive |
Prefer org-level webhooks when monitoring many repositories. Create one webhook under Organization Settings → Webhooks and it fires for every repo — no need to configure each one individually.