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 straight at Alphorn using the built-in GitHub source preset (or the per-event webhook templates), and route from there to any channel. A small proxy, like the ones below, is still useful if you want to verify GitHub's signature (Alphorn doesn't check X-Hub-Signature-256) or format each event type differently.
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: your Alphorn webhook URL, or the proxy endpoint you control (e.g.
https://proxy.example.com/github) - Content type:
application/json - Secret: a random string - your proxy uses it to verify the payload signature (Alphorn doesn't verify it)
- Events: select the events you care about (or "Send me everything")
GitHub can't send an Authorization header, so if GitHub delivers straight to Alphorn, create that webhook with No API key (URL is the secret).
The event type arrives in the X-GitHub-Event header; the action (e.g. opened, closed, completed) lives inside the JSON body.
The GitHub preset is built for issue and pull request events (title {action}: {repository.full_name}, message {issue.title}{pull_request.title}). Other events come out rough: push has no action, so the title is just : owner/repo, and events like workflow_run have no issue or PR title, so the message is empty and Alphorn falls back to the raw JSON body. For those, create a separate GitHub webhook per event type pointing at its own Alphorn webhook with a matching template, or use a proxy.
Proxy with signature verification (Node.js)
This single proxy handles multiple event types and verifies the GitHub webhook signature before forwarding anything to Alphorn. Set GITHUB_WEBHOOK_SECRET (the same value as the webhook's Secret in GitHub) and ALPHORN_API_KEY in its environment; without the secret, createHmac throws on every request.
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }));
const ALPHORN_WEBHOOK = "https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT";
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");
const a = Buffer.from(sig);
const b = Buffer.from(digest);
// timingSafeEqual throws on different lengths, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
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
try {
const r = await fetch(ALPHORN_WEBHOOK, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ALPHORN_API_KEY}`,
},
body: JSON.stringify(notification),
});
if (!r.ok) throw new Error(`Alphorn responded ${r.status}`);
res.sendStatus(200);
} catch (err) {
console.error("Forwarding to Alphorn failed:", err);
res.sendStatus(502); // GitHub marks the delivery as failed, so you can redeliver it
}
});
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/n/V1StGXR8_Z5jdHi6B-myT"
ALPHORN_HEADERS = {"Authorization": "Bearer " + os.environ["ALPHORN_API_KEY"]}
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
try:
requests.post(ALPHORN, json=payload, headers=ALPHORN_HEADERS, timeout=10).raise_for_status()
except requests.RequestException:
return "", 502
return "", 200Routing examples
| Channel | Filter | Purpose |
|---|---|---|
| PagerDuty | Tags has all of ci, failure | Page on-call for build failures |
| Slack (#security) | Tags has any of security | Dependabot and secret scanning alerts |
| Slack (#support) | Tags has all of issue, opened | Triage newly filed issues |
| Discord | Tags has any of star, fork | Growth channel - low priority |
Tags has all of deployment, 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.
Git & Repository Activity
Notify your team about pull requests, merges, releases, and code reviews using GitHub webhooks, local git post-merge hooks, and CI release steps.
FAQ
Frequently asked questions about Alphorn - self-hosting requirements, retries, message limits, plans, webhook security, SSO, 2FA, and the HTTP API.