Skip to main content

Overview

Nectr installs GitHub webhooks on repositories to receive real-time PR events. Webhook endpoint: POST /api/v1/webhooks/github Events subscribed: pull_request, issues Source: app/integrations/github/webhook_manager.py:10

Installation

install_webhook()

File: app/integrations/github/webhook_manager.py:10
async def install_webhook(
    owner: str,
    repo: str,
    access_token: str,
    backend_url: str = BACKEND_URL_DEFAULT,
) -> tuple[int, str]:
    """
    Install a GitHub webhook on the given repo.
    Returns (webhook_id, webhook_secret).
    """
    webhook_secret = secrets.token_hex(32)  # 64-char hex string
    payload_url = f"{backend_url.rstrip('/')}/api/v1/webhooks/github"

    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"https://api.github.com/repos/{owner}/{repo}/hooks",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Accept": "application/vnd.github.v3+json",
            },
            json={
                "name": "web",
                "active": True,
                "events": ["pull_request", "issues"],
                "config": {
                    "url": payload_url,
                    "content_type": "json",
                    "secret": webhook_secret,
                    "insecure_ssl": "0",
                },
            },
        )
        resp.raise_for_status()
        data = resp.json()

    webhook_id = data["id"]
    logger.info(f"Installed webhook {webhook_id} on {owner}/{repo}")
    return webhook_id, webhook_secret
Parameters:
  • owner — GitHub org/username (e.g., "octocat")
  • repo — Repository name (e.g., "hello-world")
  • access_token — GitHub personal access token or OAuth token (requires admin:repo_hook scope)
  • backend_url — Your Nectr backend URL (default: http://localhost:8000)
Returns:
  • webhook_id — Unique webhook ID from GitHub
  • webhook_secret — 64-char secret for HMAC verification
Storage: The caller must store webhook_id and webhook_secret in the database:
webhook_id, webhook_secret = await install_webhook(owner, repo, access_token, backend_url)

repo_record = RepoRecord(
    owner=owner,
    repo=repo,
    webhook_id=webhook_id,
    webhook_secret=webhook_secret,
)
db.add(repo_record)
await db.commit()

Uninstallation

uninstall_webhook()

File: app/integrations/github/webhook_manager.py:50
async def uninstall_webhook(
    owner: str,
    repo: str,
    webhook_id: int,
    access_token: str,
) -> None:
    """Delete a GitHub webhook from the given repo."""
    async with httpx.AsyncClient() as client:
        resp = await client.delete(
            f"https://api.github.com/repos/{owner}/{repo}/hooks/{webhook_id}",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Accept": "application/vnd.github.v3+json",
            },
        )
        if resp.status_code == 404:
            logger.warning(f"Webhook {webhook_id} not found on {owner}/{repo} — already deleted?")
            return
        resp.raise_for_status()
    logger.info(f"Uninstalled webhook {webhook_id} from {owner}/{repo}")
Error Handling:
  • 404 — Webhook already deleted (logs warning, doesn’t raise)
  • Other errors — Raises httpx.HTTPStatusError

Webhook Verification

HMAC Signature Check

File: app/api/v1/webhooks/github.py (typical implementation)
import hmac
import hashlib
from fastapi import HTTPException, Header

async def verify_github_signature(
    payload: bytes,
    signature: str | None = Header(None, alias="X-Hub-Signature-256"),
    webhook_secret: str,
) -> None:
    if not signature:
        raise HTTPException(403, "Missing X-Hub-Signature-256 header")

    expected = "sha256=" + hmac.new(
        webhook_secret.encode(), payload, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise HTTPException(403, "Invalid webhook signature")
Usage:
@router.post("/webhooks/github")
async def handle_github_webhook(
    request: Request,
    x_hub_signature_256: str | None = Header(None, alias="X-Hub-Signature-256"),
):
    body = await request.body()
    webhook_secret = get_webhook_secret_from_db(repo_full_name)  # fetch from DB
    await verify_github_signature(body, x_hub_signature_256, webhook_secret)

    payload = await request.json()
    # ... process webhook

Webhook Payload

Pull Request Event

Headers:
  • X-GitHub-Event: pull_request
  • X-GitHub-Delivery: <uuid>
  • X-Hub-Signature-256: sha256=<hmac>
Payload:
{
  "action": "opened" | "synchronize" | "reopened" | "closed",
  "number": 42,
  "pull_request": {
    "number": 42,
    "title": "Fix authentication bug",
    "body": "Fixes #123",
    "user": {"login": "alice"},
    "head": {"sha": "abc123..."},
    "base": {"ref": "main"},
    "state": "open",
    "html_url": "https://github.com/owner/repo/pull/42"
  },
  "repository": {
    "full_name": "owner/repo",
    "name": "repo",
    "owner": {"login": "owner"}
  },
  "sender": {"login": "alice"}
}
Actions to trigger review:
  • opened — New PR created
  • synchronize — PR updated (new commits pushed)
  • reopened — Closed PR reopened
Actions to ignore:
  • closed — PR merged/closed (no review needed)
  • labeled, unlabeled, assigned, etc. — Metadata changes only

Configuration

Environment Variables

# Backend URL for webhook payload URL
BACKEND_URL=https://nectr.example.com

# GitHub App credentials (if using GitHub App instead of OAuth)
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."

Required GitHub Scopes

For OAuth tokens, the user must grant:
  • admin:repo_hook — Install/delete webhooks
  • repo — Read PR diffs, post comments
For GitHub Apps, the app must request:
  • Repository permissions:
    • contents: read — Read file contents
    • pull_requests: write — Post review comments
    • webhooks: write — Install/delete webhooks

Database Schema

Typical table:
CREATE TABLE repositories (
    id SERIAL PRIMARY KEY,
    owner TEXT NOT NULL,
    repo TEXT NOT NULL,
    full_name TEXT GENERATED ALWAYS AS (owner || '/' || repo) STORED,
    webhook_id INTEGER,
    webhook_secret TEXT,
    installed_at TIMESTAMP DEFAULT NOW(),
    UNIQUE (owner, repo)
);

Error Scenarios

403 Forbidden

Cause: access_token doesn’t have admin:repo_hook scope. Solution: Re-authenticate user with correct scopes.

404 Not Found

Cause: Repository doesn’t exist or user doesn’t have access. Solution: Check repository name and user permissions.

422 Validation Failed

Cause: Webhook already exists with the same payload_url. Solution:
try:
    webhook_id, webhook_secret = await install_webhook(owner, repo, access_token)
except httpx.HTTPStatusError as e:
    if e.response.status_code == 422:
        logger.info(f"Webhook already exists for {owner}/{repo}")
        # Fetch existing webhook ID from GitHub API
        hooks = await github_client.list_webhooks(owner, repo)
        existing_hook = next(h for h in hooks if h["config"]["url"] == payload_url)
        webhook_id = existing_hook["id"]
    else:
        raise

Testing

Local Webhooks with ngrok

# Start ngrok tunnel
ngrok http 8000

# Copy ngrok URL (e.g., https://abc123.ngrok.io)
# Install webhook with ngrok URL
await install_webhook("owner", "repo", token, backend_url="https://abc123.ngrok.io")

Manual Webhook Trigger

GitHub UI:
  1. Go to https://github.com/owner/repo/settings/hooks
  2. Click on installed webhook
  3. Click “Recent Deliveries” → “Redeliver”

Webhook Payload Simulation

curl -X POST http://localhost:8000/api/v1/webhooks/github \
  -H "X-GitHub-Event: pull_request" \
  -H "X-Hub-Signature-256: sha256=$(echo -n '{"action":"opened"}' | openssl dgst -sha256 -hmac 'YOUR_SECRET' | sed 's/^.* //')" \
  -H "Content-Type: application/json" \
  -d '{"action":"opened","pull_request":{"number":1},"repository":{"full_name":"owner/repo"}}'

Security Best Practices

  1. Always verify signatures — Use hmac.compare_digest to prevent timing attacks
  2. Store secrets encrypted — Use cryptography.fernet or database-level encryption
  3. Rate limit webhook endpoint — GitHub can send many events quickly (e.g., force push)
  4. Use HTTPS only — Set insecure_ssl: "0" in webhook config
  5. Rotate secrets periodically — Delete + reinstall webhooks every 90 days

Monitoring

Webhook Delivery Logs

GitHub UI: https://github.com/owner/repo/settings/hooks/<webhook_id>/deliveries Metrics to track:
  • Delivery failures — 5xx errors, timeouts
  • Signature mismatches — Possible secret leak
  • High latency — Endpoint taking >5s to respond (GitHub timeout: 10s)

Alerting

# Log failed deliveries
if response.status_code >= 500:
    logger.error(f"Webhook delivery failed: {response.status_code} {response.text}")
    sentry_sdk.capture_message(f"Webhook delivery failed for {repo_full_name}", level="error")

Next Steps

Build docs developers (and LLMs) love