Chapter 27Lesson 02~235 minutes

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.

HMACWebhook fixtureDelivery IDChecksLab

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.

Availability: The mandatory path needs GitHub Free, GitHub CLI, Git, and Python 3. No organization, paid plan, public webhook endpoint, GitHub App registration, PAT, or private key is required. A live GitHub App/check-run extension is clearly optional.
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
Do not run these mutation examples against a valuable hook. Redelivery can cause downstream effects. The mandatory lab uses a local duplicate fixture instead.

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.db if 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 same valid delivery ID is sent twice. Why should the second request still receive a 2XX response?

Why is a local fake webhook secret acceptable in the mandatory lab?

What permission is needed for the optional rich check publisher?

A receiver was down for five minutes. Will GitHub automatically keep retrying until it returns?

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.

Next lesson

GitHub Apps, OAuth Apps, Webhooks, Checks API, and Event-Driven Integrations: Configuration, Design Choices, and Tradeoffs

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.