Skip to content

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

Connecting a repository from Dashboard → Integrations installs and configures the webhook for you. Read on only if you are wiring the endpoint by hand, running Lithora locally, or debugging a delivery that is not landing. See GitHub PR & CI Sync for the product-level behaviour.

Endpoint

POST/api/github/webhooks

Point 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.

What the server computes
expected = "sha256=" + HMAC_SHA256(key=GITHUB_WEBHOOK_SECRET, msg=<raw_body>).hexdigest()

Verification fails closed. You get a 401 when:

  • GITHUB_WEBHOOK_SECRET is not configured on the server.
  • X-Hub-Signature-256 is missing or does not start with sha256=.
  • The computed signature does not match (compared in constant time).

Proxy the raw bytes, unchanged

The signature covers the body exactly as GitHub sent it. Any proxy that re-serializes JSON, rewrites whitespace or strips a trailing newline will invalidate it and every delivery will 401. If you relay deliveries, forward the raw payload and the X-Hub-Signature-256, X-GitHub-Event and X-GitHub-Delivery headers verbatim.

Reproduce a valid delivery locally:

Signed ping against a local backend
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 eventX-GitHub-EventConditionNotes
github.issue_openedissuesaction = opened
github.issue_closedissuesaction = closedAlso back-syncs the linked work item.
github.issue_reopenedissuesaction = reopenedAlso back-syncs the linked work item.
github.pr_openedpull_requestaction = opened
github.pr_synchronizepull_requestaction = synchronizeNew commits pushed to an open PR.
github.pr_mergedpull_requestaction = closed and merged = true
github.pr_closedpull_requestaction = closed and merged = falseClosed without merging.
github.pushpushalways
github.release_publishedreleaseaction = published
github.check_suite_failedcheck_suiteaction = completed and conclusion = failureOnly failures are surfaced.
github.workflow_run_failedworkflow_runaction = completed and conclusion = failureCarries pr_number / on_pr when the run belongs to a PR. Drives CI-failure triage.
security.dependency_alertdependabot_alertaction is created, reopened or reintroducedNote the security.* prefix, not github.*.

Every normalized payload starts from a common base:

Base fields on every event
{
  "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

Issue payload
{
  "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

Pull request payload
{
  "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

It is parsed from GitHub closing keywords in the PR title and body — 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

workflow_run_failed and check_suite_failed
// 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

push and release_published
// 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

Dependabot advisory
{
  "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

StatusMeaning
200Signature valid; the event was queued (or the ping acknowledged). Processing is asynchronous, so a 200 means accepted, not applied.
400Signature valid but the body was not valid JSON.
401Missing or invalid signature, or the server has no signing secret configured.
429More 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

GET/api/github/webhooks/status

Authenticated. 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.