# GitHub Webhooks

> Route GitHub webhook events - issues, workflow runs, build failures, discussions, security alerts - to Slack, Discord, PagerDuty, or any other channel.

Source: https://docs.alphorn.dev/use-cases/github-webhooks/

GitHub can [send webhook events](https://docs.github.com/en/webhooks) 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](/webhook-templates#github)), 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](/use-cases/git-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](/webhook-templates#github), 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.

```javascript title="github-proxy.js"
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:

```python title="github_proxy.py"
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 "", 200
```

## Routing 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 |
| Email | Tags has all of `deployment`, `failure` | Deployment failure archive |

<Callout type="info">
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.
</Callout>
