Webhooks

Deliver a repository's events to a URL you control: a push, an issue crossing between open and closed, a pull request opened or merged.

Adding one

$ rezee webhook add https://example.com/hooks/rezee \
    --secret "$SECRET" --events push,pull_request
$ rezee webhook list
$ rezee webhook remove <id>

Webhooks belong to a repository and are the repository owner's to manage, alongside its secrets, collaborators and branch protection - not a workspace admin's.

Name the events you want. A webhook created without --events subscribes to push alone. --events all is shorthand for every one.

An event name that is not on the list below is refused rather than stored. A hook subscribed to something nothing emits looks configured on the settings page and never fires, which from the outside is indistinguishable from a quiet repository.

Changing which events an existing hook takes is a PATCH on the API; the CLI adds and removes rather than edits.

The events

EventFires whenActions
pushcommits land on any ref-
issuesan issue is filed, or crosses between open and closedopened, closed, reopened
pull_requesta pull request is opened or mergedopened, merged

An issue closed by a merge - "Closes ACME-12" in a pull request - fires issues with closed as well, so a consumer watching issues does not have to watch pull requests to notice.

The payload

JSON, always carrying the event name as a field so a single endpoint can switch on it without reading a header.

{
  "event": "push",
  "repo": "api",
  "ref": "refs/heads/master",
  "sha": "9f2c1ab...",
  "pusher": "alice"
}
{
  "event": "pull_request",
  "action": "opened",
  "repo": "api",
  "pullRequest": {
    "number": 42,
    "title": "Add encrypted pipeline secrets",
    "draft": false,
    "headBranch": "feat/secrets",
    "baseBranch": "master"
  }
}

draft is on the payload rather than left to be looked up: a consumer that starts a build or pages a reviewer on opened needs to know this one is asking for neither yet.

{
  "event": "issues",
  "action": "closed",
  "repo": "api",
  "issue": { "number": 12, "title": "Retry budget is per-process", "author": "alice" }
}

Headers

HeaderValue
X-Rezee-Eventthe event name - push, issues, pull_request
X-Rezee-Signature-256sha256=<hex>, present only when the hook has a secret
Content-Typeapplication/json
User-AgentRezee-Webhook/1.0

Verifying a delivery

The signature is an HMAC-SHA256 of the raw request body keyed with the hook's secret, hex-encoded. Compute it over the bytes you received, before any JSON parsing - a re-serialised body will not match.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string) {
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

Compare in constant time, as above. Without a secret there is no signature at all, and a receiver cannot tell your deliveries from anybody's - set one.

The secret is write-only. It is never returned by the API, the CLI or the settings page; to change it, remove the hook and add it again.

Delivery behaviour

  • Fire and forget. Deliveries are best-effort: a receiver that is down, slow, or answers 500 is not retried, and nothing about the event that caused it is affected.
  • The event still happened. A push whose webhook could not be delivered has landed; a merge is merged. Nothing is rolled back on a failed delivery.
  • Inactive hooks are skipped. A paused hook is left in place and delivers nothing.
  • No ordering guarantee. Two events close together may arrive in either order. Use the payload, not arrival order, to decide what happened.

If you need a guaranteed record rather than a notification, read the same facts back from the API - the pull request, the run, the issue - and treat the webhook as the nudge to go and look.