Webhook-Triggered Agents from GitHub Events
Verify GitHub HMAC signatures, enqueue the JSON payload, and let a worker agent comment or label, so a webhook never blocks on a model API call.
Netbay Developer Relations
Netbay Engineering
On this page
GitHub will POST issues, pull requests, and workflow_run events to any URL you publish. That URL is not an agent. If you call a model inside the request handler you will miss GitHub's timeout, retry the same ticket three times, and pay for the retries. The production shape is a thin verifier that checks the HMAC, writes the envelope, and returns 202. A worker you already run consumes the envelope and maybe comments.
This post wires that path on one Lucknow VPS: nginx or Caddy in front, a Python listener bound to localhost, systemd, and a GitHub App private key that can only label and comment. Intel Xeon Platinum is idle during the HMAC. L3/L4 filtering helps with volumetric noise; it does not replace signature checks.
Verify first, think later
GitHub signs the body with X-Hub-Signature-256. Compare with hmac.compare_digest. Reject missing signatures, reject unknown events, reject bodies over a cap. Then persist. Do not parse the issue body into a prompt on the request thread.
# /opt/agents/github_hook.py
import hmac, hashlib, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from envelope import put
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
MAX = 64 * 1024
ALLOW = {"issues", "issue_comment", "pull_request"}
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > MAX:
self.send_error(413)
return
body = self.rfile.read(length)
sig = self.headers.get("X-Hub-Signature-256", "")
digest = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(digest, sig):
self.send_error(401)
return
event = self.headers.get("X-GitHub-Event", "")
if event not in ALLOW:
self.send_response(204)
self.end_headers()
return
payload = json.loads(body.decode("utf-8"))
put("github_event", {"event": event, "payload": payload}, "hook", "triage_worker")
self.send_response(202)
self.end_headers()
HTTPServer(("127.0.0.1", 8091), Hook).serve_forever()Return 204 for events you ignore so GitHub does not retry. Return 202 only when the envelope is on disk. Delivery retries are GitHub's problem after that; duplicates are yours. Dedupe on delivery X-GitHub-Delivery, not on issue number, because a second comment is a new event.
The worker must be a GitHub App, not a PAT
A personal access token is a person. When it leaks, it is that person's repos and often their org. A GitHub App installation token is scoped to the repos you selected and the permissions you ticked: issues write, metadata read, nothing else. Generate the short-lived installation token from the App's private key at the start of each job, not at boot.
# /opt/agents/github_comment.py
import json, os, time, urllib.request
import jwt # PyJWT, RS256
def installation_token(app_id, inst_id, pem_path):
now = int(time.time())
token = jwt.encode(
{"iat": now - 30, "exp": now + 540, "iss": app_id},
open(pem_path).read(),
algorithm="RS256",
)
req = urllib.request.Request(
"https://api.github.com/app/installations/" + str(inst_id) + "/access_tokens",
data=b"",
method="POST",
headers={
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode())["token"]The PEM lives in /etc/agents/github.pem, mode 0400, owner agent-hook. The webhook secret is a different file. Neither belongs in the repo or in a compose file checked into git. If the worker is allowed to comment, it is not allowed to push.
What the agent may do with an issue
Default: label and write a draft comment to disk. Optional: post the comment if the label is kind/question and the model cited a runbook hit. Never: close, never merge, never touch actions secrets, never follow URLs in the issue body with a browser. Issue bodies are untrusted prompts; retrieval-augmented workers may read them as questions, not as instructions.
Bind the listener on 127.0.0.1 and let nginx terminate TLS with a Let's Encrypt cert on the public name. GitHub needs a reachable HTTPS URL; your agent does not need a public Python port. Rate-limit the location in nginx so a replay of an old body still has to pass HMAC and then hits a 202/401 quickly.
Delivery, retries, and idempotency
GitHub retries on non-2xx and on slow handlers. If your handler calls the model, a 30-second thinking pause becomes three comments. The envelope plus a processed-ids file makes the worker idempotent: if delivery D is done, skip. Store processed ids for 14 days, same as GitHub's retry window plus slack.
When the worker comments, include a marker in the body such as "agent:triage v1" so a later event can see the bot already spoke. Do not hide the fact that a model wrote the draft. Humans should know.
nginx in front should cap the body, set client_max_body_size 64k, and not buffer for more than a couple of seconds. The Python listener is localhost; the public name is TLS only. If you must expose a health path, make it GET /healthz with no side effects, and do not put it on the same location that accepts POSTs. GitHub does not need a health check; you do, from your own probe.
Takeaway: HMAC, 202, queue, then a GitHub App worker. The webhook is plumbing; the agent is a batch job that happens to start on an event. Point GitHub at an Ubuntu 24.04 VPS in Lucknow DC01 from Netbay — TLS and the listener fit on one box, live in under 60 seconds at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance