#Git webhook trigger
POST /webhook/git accepts a push payload from any self-hosted git
server, verifies an HMAC-SHA256 signature against a configured shared
secret, optionally filters by branch, and triggers a scan. The
endpoint bypasses bearer-token auth because the HMAC IS the auth.
#Config
[triggers]
# Shared secret resolution:
# - `env:NAME` → reads the named environment variable (recommended)
# - any other value → treated as the literal secret (testing only)
webhook_secret_ref = "env:NYX_WEBHOOK_SECRET"
# Optional branch filter; `None` accepts any branch.
webhook_branch = "main"
Set NYX_WEBHOOK_SECRET in the daemon's environment via a
systemd EnvironmentFile= drop-in installed under /etc/nyx/secret
with mode 0600 so only the daemon user can read it.
On macOS, source the secret from a 0600 sidecar file in the
LaunchAgent wrapper rather than pasting the value into the plist's
EnvironmentVariables dict. install -m 0644 on a plist makes it
world-readable, so any local user could plutil -extract EnvironmentVariables.NYX_WEBHOOK_SECRET raw - the file and forge
HMAC-valid /webhook/git deliveries (HMAC is the only auth on this
endpoint). The recommended shape is a launchd wrapper:
#!/usr/bin/env bash
set -euo pipefail
NYX_WEBHOOK_SECRET="$(cat "$HOME/.config/nyx-agent/webhook.secret")"
export NYX_WEBHOOK_SECRET
exec /usr/local/bin/nyx-agent serve --headless
Save with mode 0700 and point the LaunchAgent's ProgramArguments
at it. The sidecar file itself should be created with install -m 0600.
The handler returns HTTP 503 when the ref is configured but the env var is unset, so a misconfigured host cannot accept unauthenticated triggers.
#Wire format
| Field | Value |
|---|---|
| Method | POST |
| Path | /webhook/git |
| Header | X-Hub-Signature-256: sha256=<hex> |
| Body | JSON with at least "ref": "refs/heads/<branch>" |
X-Hub-Signature-256 is the GitHub / Gitea / Forgejo / Sourcehut
convention; the value is sha256= followed by the lowercase hex
encoding of HMAC-SHA256(secret, body_bytes).
Other fields in the payload are ignored, so a thin Gitea or Bitbucket push shape is accepted as-is.
#Response
| Status | Meaning |
|---|---|
202 Accepted |
HMAC valid, branch matched (or filter unset), scan triggered. Body: { "triggered": true, "run_id": "...", "message": "" }. |
200 OK |
HMAC valid but branch filter rejected the delivery. Body: { "triggered": false, "run_id": null, "message": "branch filter rejected..." }. |
401 Unauthorized |
Missing header, malformed signature, or HMAC mismatch. |
429 Too Many Requests |
Daemon dispatcher is saturated; the upstream git server should back off and retry. |
503 Service Unavailable |
webhook_secret_ref configured but the secret cannot be resolved (unset env var). |
The 200-on-branch-skip shape is deliberate: upstream git servers
mark the delivery as successful and stop retrying, while the operator
can still tell from the JSON whether a scan fired.
#Worked example
Operator config:
[triggers]
webhook_secret_ref = "env:NYX_WEBHOOK_SECRET"
webhook_branch = "main"
Daemon env: NYX_WEBHOOK_SECRET=hunter2.
Sign + send:
BODY='{"ref":"refs/heads/main","after":"deadbeef"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$NYX_WEBHOOK_SECRET" -hex | awk '{print $2}')"
curl -X POST http://127.0.0.1:4747/webhook/git \
-H "X-Hub-Signature-256: $SIG" \
-H 'content-type: application/json' \
-d "$BODY"
# → 202 Accepted, body: {"triggered":true,"run_id":"...","message":""}
#Self-hosted git servers
| Server | Where to paste the URL + secret |
|---|---|
| GitHub Enterprise | Settings → Webhooks → Add webhook; content type application/json; choose "Just the push event"; secret = your NYX_WEBHOOK_SECRET. |
| Gitea / Forgejo | Settings → Webhooks → Gitea → URL + secret; default events include push. |
| Bitbucket Server | Repository → Webhooks → secret + push event. |
| Sourcehut | hg/builds webhook config; signature header name is the same. |
#Security model
- The token never leaves the daemon: the HMAC verifies the body the
upstream server sent, the body must match the signature, and the
comparison is constant-time (
subtle::ConstantTimeEq). - The endpoint bypasses the SPA's bearer-token gate because it carries
its own auth, so do not put it behind a reverse proxy that strips
the
X-Hub-Signature-256header. - Body size is capped at 1 MiB; payloads above that return HTTP 400.
- The handler does not log the secret or the raw body. If a webhook fails verification, the failure surfaces as a 401 with no further detail.
#Operator checklist
-
[triggers].webhook_secret_refset innyx-agent.toml. - Matching env var exported in the daemon's process environment.
- Daemon reachable from the upstream git server (loopback if both run on the same host; otherwise put a TLS terminator in front).
-
[triggers].webhook_branchmatches the branch you actually want to scan, or leave it unset to accept every branch. - Test delivery from the git server's webhook UI returns
202.