Webhook and Health API
The always-available surface is small: a health probe, the GitHub webhook receiver, the /static assets used by the log viewer UI, and FastAPI's own /docs and /redoc. The log viewer API and WebSocket routes are always registered too — ENABLE_LOG_SERVER gates access at request time rather than registration, so with the flag off /logs/api/* answers 404 and /logs/ws closes the connection. The log viewer HTML page and /mcp are the two routes that are only registered when their feature flag is set.
| Method | Path | Purpose | Registered when |
|---|---|---|---|
GET |
/webhook_server/healthcheck |
Liveness probe | Always |
POST |
/webhook_server |
GitHub webhook receiver | Always |
GET |
/static/* |
CSS/JS assets for the log viewer UI | Always (mounted directory) |
GET |
/logs |
Log viewer UI page | ENABLE_LOG_SERVER=true |
GET |
/logs/api/* |
Log viewer API | Always (access gated at request time by ENABLE_LOG_SERVER) |
WS |
/logs/ws |
Real-time log stream | Always (closes the connection unless ENABLE_LOG_SERVER=true) |
GET |
/docs, /redoc |
FastAPI-generated interactive API docs | Always |
GET/POST/DELETE |
/mcp |
MCP streamable HTTP transport | ENABLE_MCP_SERVER=true |
The base path is not configurable. In webhook_server/app.py:
APP_URL_ROOT_PATH: str = "/webhook_server"
GET uses an f-string on that constant, so it resolves to /webhook_server/healthcheck:
@FASTAPI_APP.get(f"{APP_URL_ROOT_PATH}/healthcheck", operation_id="healthcheck")
POST passes the constant itself — no trailing segment:
@FASTAPI_APP.post(
APP_URL_ROOT_PATH, # -> POST /webhook_server
operation_id="process_webhook",
dependencies=[Depends(gate_by_allowlist_ips_dependency)],
tags=["mcp_exclude"],
)
Because the callback path is fixed, webhook-ip in config.yaml must be the full URL including that path. The server creates the GitHub hook with that exact value and content_type: "json":
webhook-ip: https://hooks.example.com/webhook_server
The server binds 0.0.0.0:5000 by default (ip-bind and port in config.yaml; see entrypoint.py).
Healthcheck
GET /webhook_server/healthcheck
The handler is synchronous and touches no config, no database, and no GitHub API. It always returns 200 with a fixed body:
{
"status": 200,
"message": "Alive"
}
status is requests.codes.ok (the integer 200), not a string. Use it as a container liveness/readiness probe; it does not verify GitHub credentials or webhook reachability.
curl -s http://localhost:5000/webhook_server/healthcheck
Webhook receive
POST /webhook_server
Content-Type: application/json
X-GitHub-Event: pull_request
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=6f1ed002ab5595859014ebf0951522d9a...
POST /webhook_server is the GitHub callback. The handler validates synchronously and then returns immediately, with the real work running as an asyncio background task.
Request headers
| Header | Required | Used for |
|---|---|---|
X-GitHub-Event |
Yes | Event routing. Missing → 400. |
X-GitHub-Delivery |
No | Delivery ID. Defaults to "unknown-delivery"; echoed in the response and used to correlate logs. |
X-Hub-Signature-256 |
Only when webhook-secret is set |
sha256= HMAC verification. Missing or mismatched → 403. |
Content-Type |
— | Body is read raw as bytes for signature verification, then parsed as JSON. |
Only X-Hub-Signature-256 is checked. The legacy SHA-1 X-Hub-Signature header is ignored.
Request body
Any GitHub event payload, but the following must be present or the request is rejected with 400:
repositoryrepository.namerepository.full_name
{
"action": "opened",
"repository": {
"name": "github-webhook-server",
"full_name": "myakove/github-webhook-server"
},
"sender": { "login": "myakove" }
}
Success response
HTTP/1.1 200 OK
content-type: application/json
{
"status": 200,
"message": "Webhook queued for processing",
"delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
"event_type": "pull_request"
}
delivery_id is the X-GitHub-Delivery value (or "unknown-delivery"), so it can be used directly against the log viewer.
Important:
200means queued, not processed. The server answers as soon as validation passes (GitHub times webhooks out at 10 seconds, while processing typically takes 5–30 seconds). Config lookup, repository validation, GitHub API calls, and all handlers run in the background.
Background processing
process_webhook builds a structured context, constructs GithubWebhook, calls await api.process(), and always calls await api.cleanup(). Errors are caught and logged; none of them change the HTTP response:
| Background failure | Where it shows up |
|---|---|
RepositoryNotFoundInConfigError |
Error log, plus a structured log entry with success: false |
httpx.ConnectError / httpx.RequestError / requests.ConnectionError |
Error log with traceback, structured log entry with success: false |
| Any other exception | Error log with traceback, structured log entry with success: false |
asyncio.CancelledError |
Re-raised (shutdown), not logged as an error |
Each run writes a structured log record (see Debug with the Log Viewer) and logs a summary with the delivery ID prefix. During shutdown the server waits up to 30 seconds for in-flight background tasks, then cancels them.
Error responses
All errors are FastAPI's standard JSON error shape.
| Status | detail |
Cause |
|---|---|---|
400 |
Missing X-GitHub-Event header |
No X-GitHub-Event header |
400 |
Failed to read request body |
Body could not be read |
400 |
Invalid JSON payload |
Body is not valid JSON |
400 |
Missing repository in payload |
No repository key |
400 |
Missing repository.name in payload |
No repository.name |
400 |
Missing repository.full_name in payload |
No repository.full_name |
400 |
Could not determine client IP address |
IP allowlist enabled, no client IP available |
400 |
Could not parse client IP address |
IP allowlist enabled, client IP unparseable |
403 |
x-hub-signature-256 header is missing! |
webhook-secret set, signature header absent |
403 |
Request signatures didn't match! |
HMAC mismatch (bad secret or tampered body) |
403 |
<ip> IP is not a valid ip in allowlist IPs |
Source IP not in the allowlist |
500 |
Configuration error |
Failure while loading config for signature verification |
{ "detail": "Request signatures didn't match!" }
Note: signature verification failures return 403, not 401 (verified in webhook_server/utils/app_utils.py and by test_process_webhook_signature_verification_failure).
Authentication
Two independent layers gate POST /webhook_server.
1. HMAC signature (secret)
Set in the root config.yaml:
webhook-secret: your-random-secret-string
At startup, entrypoint.py reads webhook-secret and passes it to repository_and_webhook_settings(webhook_secret=...), which forwards it to create_webhook → process_github_webhook. The hook is created/updated with config = {"url": webhook-ip, "content_type": "json", "secret": secret}. If an existing hook's URL matches but its secret presence differs from the configured value, the old hook is deleted and recreated so the secret always matches.
Per request, verify_signature computes:
hash_object = hmac.new(secret_token.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256)
expected_signature = "sha256=" + hash_object.hexdigest()
hmac.compare_digest(expected_signature, signature_header) # constant-time compare
The signature is computed over the raw request bytes, so any proxy that re-encodes JSON (pretty-printing, reordering keys) breaks verification. Proxies must forward the body untouched and pass X-Hub-Signature-256 through.
If webhook-secret is not set, the signature step is skipped entirely and any caller that can reach the port can post a payload. See Secure Webhooks and Pull Requests.
2. Source-IP allowlist
Set in the root config.yaml:
verify-github-ips: true
# verify-cloudflare-ips: true
During lifespan startup the server fetches the published CIDR ranges (GitHub meta API and/or the Cloudflare list) and builds a network tuple. POST /webhook_server runs gate_by_allowlist_ips_dependency → gate_by_allowlist_ips(request, ALLOWED_IPS) before the handler body:
- allowlist empty → all source IPs accepted (verification off)
- client IP inside any allowlisted network → accepted
- otherwise →
403
If verification is enabled but no valid ranges load, startup raises RuntimeError and the server refuses to start rather than accepting everything. If one source fails but the other succeeds, the server logs the error and continues with what loaded.
The check uses the client IP the app sees. Behind another proxy or load balancer you would be validating that proxy, not GitHub — terminate TLS in front of the server and forward the real address, or verify at the proxy.
Other route groups (summary)
Full reference: Log Viewer and MCP API and Debug with the Log Viewer.
Log viewer — ENABLE_LOG_SERVER=true
| Method | Path | Notes |
|---|---|---|
GET |
/logs |
HTML viewer page. Registered only inside the if LOG_SERVER_ENABLED: block. |
GET |
/logs/api/entries |
Filtered, paginated log search (limit 1–10000, default 100; offset ≥ 0). 404 if the log server is disabled. |
GET |
/logs/api/export |
Log export; format_type must match ^json$ (default json). |
GET |
/logs/api/pr-flow/{hook_id} |
PR workflow visualization for one delivery. |
GET |
/logs/api/workflow-steps/{hook_id} |
Per-step timeline for one delivery. |
GET |
/logs/api/step-logs/{hook_id}/{step_name} |
Log entries correlated to one step. Adds a trusted-network check: private, loopback, or link-local client IPs only, else 403. |
WS |
/logs/ws |
Real-time log stream with the same filters as the UI. Closes with 1008 when the log server is disabled. |
Every /logs/api/* route depends on require_log_server_enabled, which returns 404 with Log server is disabled. Set ENABLE_LOG_SERVER=true to enable. when the flag is off.
MCP — ENABLE_MCP_SERVER=true
FASTAPI_APP.add_api_route(
"/mcp",
handle_mcp_streamable_http,
methods=["GET", "POST", "DELETE"],
include_in_schema=False,
operation_id="mcp_http",
)
Streamable HTTP transport, stateless (stateless=True, json_response=True), so no session handshake is needed. The route is registered even if the fastapi_mcp import failed, so a broken MCP install yields 500 rather than 404. Routes tagged mcp_exclude — which includes POST /webhook_server — are not exposed as MCP tools. No authentication is configured: deploy on a trusted network or behind an authenticating reverse proxy.
MCP import and session-manager setup are lazy and failure-tolerant: a failed import logs and continues without MCP, and a failed session manager is torn down while the app keeps serving webhooks.
Static assets
FASTAPI_APP.mount("/static", StaticFiles(directory="webhook_server/web/static")) serves the log viewer CSS/JS. Startup fails fast if that directory is missing or is not a directory.
Full request walkthrough
# The signing key is whatever you set as webhook-secret in config.yaml.
HMAC_KEY="paste-your-webhook-secret-here"
PAYLOAD='{"action":"opened","repository":{"name":"github-webhook-server","full_name":"myakove/github-webhook-server"},"sender":{"login":"myakove"}}'
SIGNATURE="sha256=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$HMAC_KEY" | awk '{print $2}')"
curl -i -X POST http://localhost:5000/webhook_server \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request" \
-H "X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-d "$PAYLOAD"
HTTP/1.1 200 OK
{"status":200,"message":"Webhook queued for processing","delivery_id":"72d3162e-cc78-11e3-81ab-4c9367dc0958","event_type":"pull_request"}
Then follow that delivery_id in the log viewer (hook_id filter on /logs/api/entries) to see whether processing actually succeeded.
For a live OpenAPI schema, /docs and /redoc are served by FastAPI (title webhook-server) with POST /webhook_server hidden from the MCP tool surface via its mcp_exclude tag.
Configuration keys this API depends on
| Key | Effect on this API |
|---|---|
webhook-ip |
Full callback URL, including the /webhook_server path. Registered as the hook target. |
webhook-secret |
Enables X-Hub-Signature-256 verification; also set on managed hooks. |
verify-github-ips |
Adds GitHub meta CIDRs to the source-IP allowlist. |
verify-cloudflare-ips |
Adds Cloudflare CIDRs to the source-IP allowlist. |
ip-bind, port, max-workers |
Server bind address, port (default 5000), worker count. |
logs-server-log-file, mcp-log-file |
Log destinations for the log viewer and MCP server. |
See Configuration Reference for every key and Environment Variables for the feature flags.