GitHub Webhook Integration

SiftPulse receives inbound GitHub webhooks at POST /github/webhook on every event required to run PR review, issue triage, readiness re-renders, and DORA deploy tracking. This page covers setup, the exact event types we listen for, payload shape, signature verification, and a local testing recipe.

Setup — Webhook URL & Secret

Step 1
Add the payload URL

In your GitHub repo, go to Settings → Webhooks → Add webhook and set the Payload URL to:

https://siftpulse.polsia.app/github/webhook
Step 2
Set the shared secret

Set Secret to the value of the GITHUB_WEBHOOK_SECRET environment variable on the SiftPulse deployment that owns this installation. SiftPulse uses this secret to HMAC-verify every incoming delivery — see Signature verification below.

Step 3
Pick content type & enable SSL
  • Content type: application/json
  • SSL verification: enabled (default)
  • Active: checked

Subscription vs. Event Gates

When GITHUB_WEBHOOK_SECRET is set on the SiftPulse side, every incoming delivery is HMAC-verified against the raw request body using crypto.timingSafeEqual at routes/github.js:1604-1613. A missing or mismatched signature is rejected with 401.

Heads up: if GITHUB_WEBHOOK_SECRET is unset, the route accepts unsigned events. That is meant for ephemeral local development only — set a real secret for any deployment that receives traffic from GitHub.

Event Types

The handler routes events from the x-github-event header. Below are the events SiftPulse actually acts on in routes/github.js:1599-1777:

EventActionsWhat SiftPulse does
pull_request opened, reopened, synchronize, ready_for_review Runs PR review (initial or incremental) and posts the TL;DR sticky comment (routes/github.js:1775-1777).
pull_request closed (with merged=true) Captures merge metadata for DORA deploy tracking (routes/github.js:1752-1773).
issues opened, reopened, … Runs issue triage via handleIssueEvent (routes/github.js:1630-1632; handler at :419).
pull_request_review submitted, dismissed Re-renders the readiness merge-state comment on the PR (routes/github.js:1650-1679).
check_suite completed Re-renders readiness for every PR in the suite (routes/github.js:1681-1712).
status (any) Re-renders readiness for open PRs whose head SHA matches (routes/github.js:1714-1750).
installation_repositories added Triggers retro backfill on newly added repos (routes/github.js:1635-1648).
Note: SiftPulse does not currently subscribe to issue_comment events. Triage runs on issues open/reopen, not on comments. Subscribe the events above and you'll cover every code path that powers reviews, triage, readiness, and DORA.

Payload Format

SiftPulse reads the standard GitHub webhook payload (full schema in GitHub's Webhooks docs). The fields we use are:

{
  "action": "opened",
  "pull_request": {
    "number": 482,
    "title": "Add weekly digest email",
    "user": { "login": "octocat", "type": "User" },
    "head": { "sha": "abc123..." },
    "merged": false,
    "merge_commit_sha": null,
    "labels": []
  },
  "issue": {
    "number": 91,
    "title": "OAuth callback returns 500"
  },
  "repository": {
    "id": 1296269,
    "name": "hello-world",
    "owner": { "login": "octocat" }
  },
  "installation": { "id": 1 },
  "sender": { "login": "octocat" }
}

Headers we read on every delivery:

Signature Verification

Every request is verified by computing HMAC-SHA256(key=GITHUB_WEBHOOK_SECRET, msg=raw_body) and constant-time-comparing against the x-hub-signature-256 header. The exact code (routes/github.js:1605-1610):

const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(req.body)
  .digest('hex');

if (!crypto.timingSafeEqual(
  Buffer.from(sig),
  Buffer.from(expected)
)) {
  return res.status(401).send('Invalid signature');
}

A malformed header returns 401 Missing signature (routes/github.js:1611-1613).

Testing Locally with smee.io

smee.io is the GitHub-recommended relay for forwarding webhooks from github.com to a local dev server. It avoids needing a public URL or ngrok.

Step 1
Start a smee channel

Open smee.io/new in your browser to mint a unique channel URL. Then run the relay client locally:

npx smee-client \
  --url https://smee.io/<your-channel> \
  --target http://localhost:3000/github/webhook
Step 2
Point GitHub at the smee URL

In your test repo, Settings → Webhooks → Add webhook, and use the smee URL as the Payload URL. Set Secret to the same value you have in your local .env as GITHUB_WEBHOOK_SECRET.

Step 3
Redeliver past events

In the webhooks list, pick any historical delivery and click Redeliver. This is the fastest way to exercise the handler end-to-end without opening a real PR — useful for debugging the initial review, the TL;DR comment, and readiness re-renders.

Idempotency & Retries

GitHub retries on any non-2xx response. SiftPulse responds 200 ok immediately at routes/github.js:1616 — before parsing the body — so retries are cheap and harmless. Audit tables (pr_review_events, triage_events, pr_readiness_state, retro_reviews, tldr_comment_events, suggestion_events) all use upsert or insert-once paths, so redelivery of the same x-github-delivery will not double-post.

Smoke endpoint: the public deploy-webhook receiver exposes GET /webhooks/render/ping (routes/webhooks.js:175-177) as a basic liveness probe. There is no equivalent GET /github/webhook/ping at this time — use the smee redelivery flow above to exercise the GitHub handler.

Endpoint URL Legend

SiftPulse accepts inbound webhooks on two distinct endpoints. There is no plain /webhook route — use the specific URL for the system that's calling you. Both endpoints speak application/json and both verify with HMAC-SHA256 over the raw request body.

EndpointAuthSignature headerEnv var
POST /github/webhook HMAC-SHA256 (required) x-hub-signature-256 GITHUB_WEBHOOK_SECRET
POST /webhooks/render HMAC-SHA256 (optional) x-render-signature or x-webhook-signature RENDER_WEBHOOK_SECRET

Deploy Webhook (/webhooks/render)

Owned by routes/webhooks.js. SiftPulse receives Render deploy events (and similar generic-CI deploy events that follow the same shape) and uses them to populate DORA lead-time metrics — every successful or failed deploy is linked back to the PR whose merge commit SHA it carries.

Setup

In the Render dashboard, open Service → Webhooks → Add Webhook, set URL to:

https://siftpulse.polsia.app/webhooks/render

If you set a secret in the Render UI, make sure it matches RENDER_WEBHOOK_SECRET on the SiftPulse deployment. If the secret is omitted on either side, SiftPulse accepts unsigned events — fine for ephemeral local dev only, but never use that for a deployment that receives real traffic.

Event types

Three event types map onto SiftPulse's internal deploy_status via the normalizeStatus branches at routes/webhooks.js:30-36:

Render's envelope is { type: "deploy", action: "succeeded", ... } (routes/webhooks.js:120-123). Generic CI providers that send action-only payloads (e.g. { action: "success" } or { status: "failed" }) are normalized into the same three buckets so the same downstream code applies.

Payload fields

SiftPulse reads the following fields. The full payload is also persisted verbatim to deploy_events.raw_payload (JSONB) for audit and replay.

Signature verification

When RENDER_WEBHOOK_SECRET is set, every request is HMAC-verified. Computed on the raw body, prefixed with sha256=, and compared against x-render-signature (with x-webhook-signature as a fallback for generic CI providers):

const sig = req.headers['x-render-signature']
  || req.headers['x-webhook-signature']
  || '';
const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(rawBody)
  .digest('hex');
if (!sig || sig !== expected) {
  return;
}

Exact source at routes/webhooks.js:102-110.

Idempotency & persistence

SiftPulse responds 200 ok immediately at routes/webhooks.js:91 — before body parsing — so deploy providers that retry on non-2xx won't pile up. Every event inserts a row into deploy_events; on succeeded / failed, services/deploy-tracking.js#processDeployCompleted runs asynchronously to populate pr_deploy_links (the join table that powers DORA lead-time reporting).

Smoke-test the endpoint with GET /webhooks/render/ping — it returns {"ok":true,"service":"siftpulse","endpoint":"/webhooks/render"} with no auth required.

Verifying webhook signatures in your own code

If you want to mirror SiftPulse's verification on the receiving side (for example, a backend that consumes deploy events from SiftPulse in the future), here's the same constant-time HMAC pattern in two languages.

Node.js

const crypto = require('crypto');

function verify(signatureHeader, rawBody, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(signatureHeader || '');
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Python

import hmac
import hashlib

def verify(signature_header: str, raw_body: bytes, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature_header or '', expected)

Local .env Checklist

PORT=3000
GITHUB_WEBHOOK_SECRET=<strong 32-byte random hex>
DATABASE_URL=postgres://...
GITHUB_APP_ID=<numeric>
GITHUB_APP_PRIVATE_KEY="<PEM, newlines escaped>"

Next steps

Issue triage docs → DORA setup → GitHub Action setup → View pricing →