GitHub Apps, OAuth Apps, Webhooks, Checks API, and Event-Driven Integrations: Guided Hands-On Workflow and Core Operations
Create a disposable repository plus a local signed-webhook receiver that verifies HMAC, deduplicates redeliveries, preserves evidence, and keeps all GitHub mutations optional.
Learning objectives
- Create and inspect a disposable public repository without creating a real integration credential.
- Implement raw-body HMAC-SHA256 validation with constant-time comparison.
- Deduplicate signed deliveries durably using X-GitHub-Delivery.
- Interpret hosted delivery/redelivery APIs and check-run contracts.
- Keep the mandatory mutation path dry-run and free-compatible.
1. Scenario and preflight: a disposable integration service without a real credential
Create a public repository named
atlas-c27-integration-lab. The repository anchors a
real Git ref and lets you inspect hosted metadata, but the mandatory
webhook path stays local and uses synthetic IDs/secret material.
This avoids generating a real App private key or exposing a tunnel
endpoint merely to learn HMAC, delivery identity, and idempotence.
gh auth status
gh repo create atlas-c27-integration-lab --public --clone --add-readme
cd atlas-c27-integration-lab
git rev-parse HEAD
gh repo view --json nameWithOwner,visibility,defaultBranchRef
Before state: one public repository and one source commit exist. No webhook/App installation/check run has been created by this lab.
2. Design the App before creating one
Write the permission/event matrix first. For the training service,
the inbound trigger is an issues event. The mandatory
action is dry-run only, so no GitHub write permission is needed. An
optional rich check publisher would add
Checks: write and install the App only on this
disposable repository.
| Need | Repository permission | Webhook event | Why |
|---|---|---|---|
| Read issue identity for policy evaluation | Issues: read | issues | Payload identifies the issue; API read can reconcile current state. |
| Read source commit metadata | Contents: read | push or pull_request only if truly needed | Avoid subscribing to unrelated event volume. |
| Publish optional rich check | Checks: write | check_suite/check_run only if service supports reruns | Write permission is added only for the optional path. |
| Organization administration | None | None | Not required; do not request it “for future use.” |
Repository installation scope should be “selected repositories” for a focused service. Moving from one selected repository to all repositories is an authorization expansion and should be reviewed like a production privilege change.
3. Build a local receiver that verifies before it trusts
Before using the richer JSON fixture, validate your HMAC
implementation against GitHub's published test vector: secret
It's a Secret to Everybody, payload
Hello, World!, and expected
X-Hub-Signature-256 value
sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. This published training value authenticates no GitHub account.
The receiver below uses only Python's standard library. It verifies
HMAC on raw bytes, parses JSON only after verification, persists
X-GitHub-Delivery in SQLite with a primary-key
uniqueness constraint, and logs only safe fields. The derived action
is deliberately a dry-run string.
#!/usr/bin/env python3
import hashlib, hmac, json, os, sqlite3, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ.get("CH27_WEBHOOK_SECRET", "chapter27-local-only").encode()
DB = "chapter27-deliveries.db"
def init_db():
with sqlite3.connect(DB) as db:
db.execute("""
CREATE TABLE IF NOT EXISTS deliveries (
delivery_id TEXT PRIMARY KEY,
event TEXT NOT NULL,
action TEXT,
payload_sha256 TEXT NOT NULL,
accepted_at INTEGER NOT NULL
)
""")
def remember(delivery_id, event, action, raw):
digest = hashlib.sha256(raw).hexdigest()
try:
with sqlite3.connect(DB) as db:
db.execute(
"INSERT INTO deliveries VALUES (?, ?, ?, ?, ?)",
(delivery_id, event, action, digest, int(time.time())),
)
return True, digest
except sqlite3.IntegrityError:
return False, digest
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhook":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
signature = self.headers.get("X-Hub-Signature-256", "")
delivery_id = self.headers.get("X-GitHub-Delivery", "")
event = self.headers.get("X-GitHub-Event", "")
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not signature or not hmac.compare_digest(signature, expected):
self.send_response(401); self.end_headers(); self.wfile.write(b"invalid signature\n")
return
if not delivery_id:
self.send_response(400); self.end_headers(); self.wfile.write(b"missing delivery id\n")
return
payload = json.loads(raw.decode("utf-8"))
action = payload.get("action")
first, digest = remember(delivery_id, event, action, raw)
if not first:
self.send_response(200); self.end_headers(); self.wfile.write(b"duplicate ignored\n")
return
# Mandatory lab: safe derived action is dry-run only.
repo = payload.get("repository", {}).get("full_name", "unknown")
issue = payload.get("issue", {}).get("number")
if event == "issues" and action == "opened":
print(json.dumps({
"delivery": delivery_id,
"event": event,
"repo": repo,
"issue": issue,
"payload_sha256": digest,
"derived_action": "DRY_RUN: would enqueue policy evaluation"
}))
self.send_response(202); self.end_headers(); self.wfile.write(b"accepted\n")
def log_message(self, fmt, *args):
# Keep logs small; never print the secret, signature, or full payload.
print("http", self.address_string(), fmt % args)
if __name__ == "__main__":
init_db()
print("Listening on http://127.0.0.1:8787/webhook")
ThreadingHTTPServer(("127.0.0.1", 8787), Handler).serve_forever()
Save it as receiver.py. Use a synthetic local secret;
this value authenticates nothing outside your machine.
export CH27_WEBHOOK_SECRET='chapter27-local-only'
python receiver.py
PowerShell:
$env:CH27_WEBHOOK_SECRET='chapter27-local-only'
python .\receiver.py
4. Replay a signed GitHub-shaped fixture
Open a second terminal and save this as
send_fixture.py. The sender signs the exact compact
JSON bytes that it transmits.
#!/usr/bin/env python3
import argparse, hashlib, hmac, json, os, urllib.request, urllib.error
SECRET = os.environ.get("CH27_WEBHOOK_SECRET", "chapter27-local-only").encode()
p = argparse.ArgumentParser()
p.add_argument("--delivery", default="11111111-2222-4333-8444-555555555555")
p.add_argument("--bad-signature", action="store_true")
args = p.parse_args()
payload = {
"action": "opened",
"repository": {"id": 27001, "full_name": "learner-example/atlas-c27-integration-lab"},
"issue": {"number": 27, "title": "Training fixture only"},
"installation": {"id": 990027}
}
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
valid = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
sig = "sha256=" + ("0" * 64) if args.bad_signature else valid
headers = {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-GitHub-Delivery": args.delivery,
"X-Hub-Signature-256": sig,
}
req = urllib.request.Request("http://127.0.0.1:8787/webhook", data=raw, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as r:
print(r.status, r.read().decode().strip())
except urllib.error.HTTPError as e:
print(e.code, e.read().decode().strip())
python send_fixture.py
# Expected: 202 accepted
python send_fixture.py
# Same delivery GUID: expected 200 duplicate ignored
python send_fixture.py --bad-signature
# Expected: 401 invalid signature
The duplicate response is not an error. It is evidence that redelivery/replay cannot repeat the derived operation. The bad-signature response proves that untrusted JSON never reaches the business-action path.
5. Inspect durable evidence instead of trusting console text
python - <<'PY'
import sqlite3, json
with sqlite3.connect('chapter27-deliveries.db') as db:
rows=db.execute('SELECT delivery_id,event,action,payload_sha256,accepted_at FROM deliveries').fetchall()
print(json.dumps(rows, indent=2))
PY
There should be exactly one row for the repeated valid delivery ID. The invalid-signature attempt must not insert a row. This is a causal proof: HMAC gate first, uniqueness gate second, derived action third.
6. Relate the fixture to GitHub's hosted delivery model
For a real webhook, the receiver would get the same conceptual
headers from GitHub. GitHub recommends acknowledging in under ten
seconds and moving expensive work to a queue. Failed deliveries are
recorded but not automatically redelivered. Operators can inspect
and redeliver deliveries from the recent three-day window; a
redelivery keeps the same X-GitHub-Delivery, so the
SQLite strategy remains valid.
For a repository webhook with admin rights, current REST inspection/redelivery shapes are:
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
repos/OWNER/REPO/hooks/HOOK_ID/deliveries --jq '.[] | {id,guid,status,status_code,event,redelivery}'
gh api --method POST -H "X-GitHub-Api-Version: 2026-03-10" \
repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID/attempts
7. Optional extension: inspect the Checks API contract before publishing
First inspect the current commit's check runs read-only:
SHA=$(git rev-parse HEAD)
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
repos/OWNER/atlas-c27-integration-lab/commits/$SHA/check-runs \
--jq '{total_count,check_runs:[.check_runs[]|{id,name,status,conclusion,app:.app.slug}]}'
A live check creation is optional because it requires a GitHub App
identity with Checks: write and an installation/user
access token for that App. The request shape is:
{
"name": "chapter27-policy",
"head_sha": "COMMIT_SHA",
"status": "completed",
"conclusion": "success",
"output": {
"title": "Chapter 27 fixture",
"summary": "Signed webhook fixture was accepted and deduplicated."
}
}
# OPTIONAL ONLY: run with a short-lived GitHub App token held outside shell history/logs.
curl -L --request POST \
--url "https://api.github.com/repos/OWNER/REPO/check-runs" \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer INSTALLATION_ACCESS_TOKEN" \
--header "X-GitHub-Api-Version: 2026-03-10" \
--data @check-run.json
Expected success is HTTP 201 Created with a check-run
ID, name, status/conclusion, head SHA, and App identity. A later
state transition should update that known run rather than blindly
creating another one.
# OPTIONAL update shape; CHECK_RUN_ID comes from the 201 response/read-back.
curl -L --request PATCH --url "https://api.github.com/repos/OWNER/REPO/check-runs/CHECK_RUN_ID" --header "Accept: application/vnd.github+json" --header "Authorization: Bearer INSTALLATION_ACCESS_TOKEN" --header "X-GitHub-Api-Version: 2026-03-10" --data '{"status":"completed","conclusion":"success"}'
Never paste a real installation token into a lesson, issue, terminal transcript, or source file. If the update response is lost, read the check-run ID/state before retrying.
8. Challenge: choose the right surface
For each requirement, choose the smallest control and justify it: (a) notify an external service when issues are opened; (b) expose rich line-level CI annotations on commits; (c) run a one-time personal inventory; (d) let a website act as a signed-in human; (e) recover an event after a two-minute receiver outage. Good answers should distinguish webhook versus polling, Checks versus status, GitHub App versus PAT/OAuth, and redelivery versus blind mutation retry.
9. Verification and cleanup
- Verify one accepted delivery row and one duplicate suppression.
- Verify a bad signature produced HTTP 401 and no row.
- Verify no private key, PAT, installation token, or real webhook secret exists in the repository.
-
Stop the local receiver and remove
chapter27-deliveries.dbif you do not need the evidence. - If you performed the optional live App extension, uninstall it from the disposable repo, revoke/delete generated private keys, rotate/remove the webhook secret, and verify the installation is gone.
Knowledge check
Why does the receiver parse JSON only after HMAC verification?
The HMAC is defined over the exact raw bytes GitHub sent. Parsing first both exposes untrusted content to more code and may change the serialized bytes used for verification.
The same valid delivery ID is sent twice. Why should the second request still receive a 2XX response?
The event was already processed; acknowledging the duplicate prevents needless redelivery while the durable uniqueness record prevents the derived mutation from running twice.
Why is a local fake webhook secret acceptable in the mandatory lab?
It protects only synthetic localhost traffic and authenticates no GitHub account or external system. A real webhook secret would be a credential and would require secure creation/storage/rotation.
What permission is needed for the optional rich check publisher?
A GitHub App must have Checks write permission for the target repository and an appropriate short-lived App access token. The mandatory lab does not grant it.
A receiver was down for five minutes. Will GitHub automatically keep retrying until it returns?
No. GitHub records failed deliveries but does not automatically redeliver them. Operators/services must use recent-delivery inspection and explicit redelivery/recovery logic.
Summary
You built the integration ingress path without a real credential: verify HMAC over raw bytes, require delivery/event metadata, persist an idempotency key, keep the derived action dry-run, and inspect durable evidence. You also mapped how a real GitHub App installation token and Checks write permission would extend that path.
Official references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.