GitHub webhooks
Lithora verifies every inbound GitHub delivery, normalizes it into a stable event name, and publishes it onto the integration event bus that drives issue sync, automations and cycle-time analytics.
Most people never need this page
Endpoint
/api/github/webhooksPoint a repository- or organisation-level GitHub webhook here with content type application/json. Rate limited to 120 deliveries per minute.
This is the one endpoint on the API that does not take a Bearer credential — it authenticates the HMAC signature GitHub sends instead. GitHub fires a ping when you first save the webhook; Lithora answers { "received": true, "ping": true } once the signature checks out.
Signature verification (fail-closed)
Every request must carry an HMAC-SHA256 signature of the raw request body in the X-Hub-Signature-256 header, formatted sha256=<hex>. The shared secret is the GITHUB_WEBHOOK_SECRET environment variable; set the identical value in the GitHub webhook config.
expected = "sha256=" + HMAC_SHA256(key=GITHUB_WEBHOOK_SECRET, msg=<raw_body>).hexdigest()Verification fails closed. You get a 401 when:
GITHUB_WEBHOOK_SECRETis not configured on the server.X-Hub-Signature-256is missing or does not start withsha256=.- The computed signature does not match (compared in constant time).
Proxy the raw bytes, unchanged
X-Hub-Signature-256, X-GitHub-Event and X-GitHub-Delivery headers verbatim.Reproduce a valid delivery locally:
BODY='{"zen":"Keep it logically awesome."}'
SIG="sha256=$(printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" \
| awk '{print $2}')"
curl -s -X POST "http://localhost:8000/api/github/webhooks" \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: ping" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"
# -> {"received": true, "ping": true}Idempotency
GitHub retries failed deliveries, and redelivering from the GitHub UI reuses the same X-GitHub-Delivery GUID. Lithora treats that GUID as an idempotency key and drops a delivery it has already handled, so a retry cannot double-apply — duplicate work items, status flapping. GitHub sends no signed timestamp, so this dedup is also what provides replay protection. Never strip or regenerate that header.
Normalized events
Lithora maps a (X-GitHub-Event, action) pair onto a single normalized event name. Anything not in this table is accepted with a 200 and dropped, so adding extra event types to your webhook config is harmless.
| Normalized event | X-GitHub-Event | Condition | Notes |
|---|---|---|---|
| github.issue_opened | issues | action = opened | |
| github.issue_closed | issues | action = closed | Also back-syncs the linked work item. |
| github.issue_reopened | issues | action = reopened | Also back-syncs the linked work item. |
| github.pr_opened | pull_request | action = opened | |
| github.pr_synchronize | pull_request | action = synchronize | New commits pushed to an open PR. |
| github.pr_merged | pull_request | action = closed and merged = true | |
| github.pr_closed | pull_request | action = closed and merged = false | Closed without merging. |
| github.push | push | always | |
| github.release_published | release | action = published | |
| github.check_suite_failed | check_suite | action = completed and conclusion = failure | Only failures are surfaced. |
| github.workflow_run_failed | workflow_run | action = completed and conclusion = failure | Carries pr_number / on_pr when the run belongs to a PR. Drives CI-failure triage. |
| security.dependency_alert | dependabot_alert | action is created, reopened or reintroduced | Note the security.* prefix, not github.*. |
Every normalized payload starts from a common base:
{
"repo": "acme/widgets", // repository.full_name (nullable)
"sender": "octocat", // sender.login (nullable)
"action": "opened" // the original GitHub action (nullable)
}Payload shapes
github.issue_opened / issue_closed / issue_reopened
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "opened",
"issue_number": 42,
"title": "Login button is misaligned",
"url": "https://github.com/acme/widgets/issues/42",
"created_at": "2026-07-28T10:04:11Z",
"labels": ["bug", "frontend"]
}github.pr_opened / pr_synchronize / pr_merged / pr_closed
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "closed",
"pr_number": 128,
"title": "Fix login button alignment",
"url": "https://github.com/acme/widgets/pull/128",
"merged": true,
"created_at": "2026-07-28T10:04:11Z",
"merged_at": "2026-07-29T15:22:03Z",
"base": "main",
"head": "fix/login-button",
"head_sha": "9f1c2d3e4b5a6789...",
"closes_issues": [42]
}closes_issues is what links a PR to a work item
Closes #42, Fixes #42. If a merged PR is not advancing the task you expected, check the closing keyword first: without one there is nothing to link.CI failures
// github.workflow_run_failed
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "completed",
"run_id": 9182736450,
"branch": "fix/login-button",
"sha": "9f1c2d3e4b5a6789...",
"name": "CI",
"url": "https://github.com/acme/widgets/actions/runs/9182736450",
"pr_number": 128,
"on_pr": true
}
// github.check_suite_failed
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "completed",
"branch": "main",
"sha": "9f1c2d3e4b5a6789..."
}Pushes and releases
// github.push
{
"repo": "acme/widgets",
"sender": "octocat",
"action": null,
"ref": "refs/heads/main",
"commit_count": 2,
"messages": ["Fix login button alignment", "Add regression test"]
}
// github.release_published
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "published",
"tag": "v2.0.0",
"name": "Widgets 2.0",
"url": "https://github.com/acme/widgets/releases/tag/v2.0.0"
}messages is capped at the first 20 commit messages in the push.
security.dependency_alert
{
"repo": "acme/widgets",
"sender": "octocat",
"action": "created",
"ghsa_id": "GHSA-xxxx-yyyy-zzzz",
"cve_id": "CVE-2026-12345",
"severity": "high",
"package": "left-pad",
"ecosystem": "npm",
"vulnerable_range": "< 1.3.1",
"fixed_version": "1.3.1",
"cvss_score": 7.5,
"url": "https://github.com/acme/widgets/security/dependabot/7"
}Responses
| Status | Meaning |
|---|---|
| 200 | Signature valid; the event was queued (or the ping acknowledged). Processing is asynchronous, so a 200 means accepted, not applied. |
| 400 | Signature valid but the body was not valid JSON. |
| 401 | Missing or invalid signature, or the server has no signing secret configured. |
| 429 | More than 120 deliveries in a minute. |
Errors use the same structured envelope as the rest of the API — see the errors section.
Checking delivery health
/api/github/webhooks/statusAuthenticated. Reports whether the signing secret is configured, the total number of signature-valid deliveries received, and when the last receipt and last signature failure occurred.
This is the same data behind the GitHub card in Settings → Integrations. If received_total is not moving, the problem is upstream of Lithora — check the Recent Deliveries panel in GitHub. If last_signature_failure_at is recent, your secret does not match on both sides.