# Webhooks

> Unauthenticated webhook endpoints for GitHub event delivery and build-runner callbacks — HMAC and JWT verified.

Source: https://zop.dev/developer-docs/deployments/webhooks

---

ZopNight exposes two unauthenticated webhook endpoints — one for inbound events from GitHub, and one for the build runner reporting back. Both use cryptographic verification rather than JWT session auth so they can be called without a ZopNight login.

**Info**

Both webhook endpoints are served by the **deployer** service. The gateway forwards them through unauthenticated; the deployer's handler does the HMAC / JWT check itself.

**Warning**

Anyone who can sign a request with the right secret can deliver events ZopNight will trust. Store secrets in your secret manager, rotate them when an integration is removed, and audit which integrations carry which secret.

## GitHub Webhook

The GitHub app installed via the [GitHub integration](https://zop.dev/docs/integrations) delivers `push` and `pull_request` events to this endpoint. The deployer verifies the HMAC against `GITHUB_APP_WEBHOOK_SECRET`, then fans out to every deployment whose `repo_url + branch + auto_deploy=true` matches.

### POST /webhooks/github

GitHub event delivery endpoint. Authenticated via X-Hub-Signature-256 (HMAC-SHA256 of the request body, keyed with GITHUB_APP_WEBHOOK_SECRET). (deployer)

```bash title="Headers GitHub sends"
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11ed-...
X-Hub-Signature-256: sha256=<hex digest>
Content-Type: application/json
```

```bash title="HMAC verification (Go)"
# Pseudocode — ZopNight does this server-side
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(headerValue)) {
    return 401
}
```

### Idempotency: delivery_id as revision PK

Since Phase 3b-1 (May 2026), GitHub's `X-GitHub-Delivery` is hashed into a deterministic UUID v5 (namespace: the deployment ID) and used as the primary key of the resulting `deployment_revisions` row. The deployer issues an `INSERT IGNORE` when it creates the revision, so replaying the same GitHub delivery is a no-op — no duplicate builds, no duplicate Helm releases, no manual reconciliation.

This replaces the older `webhook_dedup` ledger table, which is still present in the deployer's schema for backwards compatibility but is no longer written to. The drop migration is scheduled for Phase 4.

### Fan-out across deployments

When a `push` arrives, the deployer needs the list of deployments to react on. Since Phase 3c, the catalog lives on config, so the deployer calls config's gRPC:

```bash title="gRPC (deployer → config)"
rpc ListDeploymentsByRepoAndBranch(
  ListDeploymentsByRepoAndBranchRequest { repo_url, branch }
) returns (DeploymentList);
// Indexed by idx_deployments_repo_branch (repo_url(191), branch, auto_deploy)
// to keep webhook fan-out under the same 50ms budget as the old local query.
```

### Events handled

| Event | Effect |
| --- | --- |
| `push` | For every matching deployment with `auto_deploy=true`: `INSERT IGNORE` a new revision keyed by the delivery UUID, then enqueue a build job. |
| `pull_request` | Run analysis (no build, no deploy) and post a status check back to GitHub. |
| `installation` / `installation_repositories` | Sync the org's available repositories list. |

## Build Callback

The GitHub Actions dispatcher that runs builds on your behalf reports completion back to ZopNight via this endpoint. The callback carries a short-lived JWT signed with the deployer's `BUILD_CALLBACK_SECRET` in the request body — the gateway accepts the request unauthenticated, then the deployer validates the body token before promoting the revision. If the secret isn't set on the deployer, the route isn't registered at all.

### POST /build-callbacks/{revisionID}

Build runner reports image readiness for a revision. (deployer)

```bash title="Request"
curl -X POST https://zopnight.com/api/build-callbacks/rev_005 \
  -H "Content-Type: application/json" \
  -d '{
    "token": "<JWT signed with BUILD_CALLBACK_SECRET>",
    "status": "succeeded",
    "imageRef": "registry.zopnight.com/acme/checkout:v1.4.3",
    "logsURL": "https://github.com/acme/checkout/actions/runs/123",
    "completedAt": "2026-04-29T11:04:30Z"
  }'
```

```json title="Failure example"
{
  "token": "<JWT signed with BUILD_CALLBACK_SECRET>",
  "status": "failed",
  "failureReason": "build step exited with code 1: missing Dockerfile",
  "logsURL": "https://github.com/acme/checkout/actions/runs/124"
}
```

### Token claims

| Claim | Description |
| --- | --- |
| `rev` | The revision ID (must match the URL path). |
| `iat` | Issued-at; the deployer rejects tokens older than 30 minutes. |
| `exp` | Expiry; required. |

See [Builds](https://zop.dev/docs/deployments/builds) for how revisions and image references flow through the deploy pipeline, and [Deployments](https://zop.dev/docs/deployments/deployments) for the runtime endpoints that consume the revisions created here.
