Tracker webhook ingestion¶
Bernstein can ingest tracker events via inbound webhooks instead of (or alongside) the polling loop. Polling stays the default; webhook ingestion is opt-in per adapter via bernstein.yaml.
The receiver verifies the per-tracker HMAC signature, deduplicates deliveries using a bounded in-memory + on-disk ledger, and produces the same normalised Ticket objects the polling path emits. The result feeds into the orchestrator's existing task queue without code changes downstream.
Why webhooks¶
Polling intervals between 30 seconds and five minutes cap the time between a tracker change and a Bernstein response. Webhooks remove that floor and free a portion of the tracker's rate-limit budget that polling spent on no-op fetches.
Configuration¶
Add a webhook block under the tracker entry in bernstein.yaml:
trackers:
jira_cloud:
webhook:
enabled: true
secret_env: JIRA_CLOUD_WEBHOOK_SECRET
public_url_base: https://bernstein.example.com
| Key | Required | Description |
|---|---|---|
enabled | yes | When false, the route returns 503 so the tracker stops retrying. |
secret_env | yes | Environment variable holding the shared HMAC secret. Resolved at request time so secret rotation does not require a restart. |
public_url_base | advisory | Reverse-proxy URL operators register with the tracker. Documented here so the operator can paste it into the tracker UI. |
The webhook endpoint is POST /webhooks/trackers/<adapter_name> where <adapter_name> is the short tracker name (jira_cloud, github, gitlab, linear, plane, ...).
Supported trackers¶
Built-in handlers ship for:
| Adapter | Verification header | Delivery-id header |
|---|---|---|
jira_cloud | X-Hub-Signature-256 (HMAC-SHA256, sha256= prefix) | X-Atlassian-Webhook-Identifier |
github | X-Hub-Signature-256 (HMAC-SHA256, sha256= prefix) | X-GitHub-Delivery |
gitlab | X-Gitlab-Token (constant-time compare) | X-Gitlab-Event-UUID |
linear | Linear-Signature (HMAC-SHA256, raw hex) | Linear-Delivery |
plane | X-Plane-Signature (HMAC-SHA256, raw hex) | X-Plane-Delivery |
Adapters that ship outside the core package can register handlers by calling bernstein.core.trackers.webhook_receiver.register_handler(WebhookHandler(...)) during import.
Replay protection¶
Each delivery is keyed by the tracker-provided delivery id (header above) or, when the tracker omits one, a SHA-256 of the raw body. The receiver keeps the last 4096 ids in memory and appends an entry to .sdd/runtime/tracker_webhook_ledger.jsonl so restarts do not lose replay state. Re-delivery returns HTTP 200 with status: replay so the tracker treats the duplicate as accepted without writing it through again.
Startup-poll recovery¶
On boot the orchestrator may call bernstein.core.trackers.webhook_receiver.replay_recent_via_poll to catch events that the tracker tried to deliver while Bernstein was down. The helper runs a single poll, filters tickets older than the caller-supplied last_processed_ts, and feeds the rest into the same sink the webhook route uses. Adapters that do not populate raw["updated_at"] simply replay all open tickets, which is the safe default.
Reverse-proxy setup¶
Most trackers require an HTTPS endpoint. Two patterns work today:
nginx / caddy¶
Forward https://bernstein.example.com/webhooks/trackers/<adapter> to the bernstein server's POST /webhooks/trackers/<adapter> route. The receiver consumes the raw body, so any proxy that preserves bytes will work.
ngrok / cloudflared¶
Operators running Bernstein on a laptop can use the existing tunnel subsystem. Start the tunnel (bernstein preview, or any tunnel provider registered under bernstein.core.tunnels) and paste the resulting public URL plus /webhooks/trackers/<adapter> into the tracker's webhook configuration UI.
Verifying a delivery locally¶
curl -sX POST http://localhost:8000/webhooks/trackers/github \
-H "x-github-event: issues" \
-H "x-github-delivery: $(uuidgen)" \
-H "x-hub-signature-256: sha256=$(python -c \
'import hmac,hashlib,sys; print(hmac.new(b"shh", sys.stdin.buffer.read(), hashlib.sha256).hexdigest())' \
< payload.json)" \
--data-binary @payload.json
A 200 response with status: accepted confirms verification, parsing, and dedup all succeeded. A 401 means the signature did not match; 503 means the endpoint is disabled or the secret_env variable is empty.