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
| Event | Fires when | Actions |
|---|---|---|
push | commits land on any ref | - |
issues | an issue is filed, or crosses between open and closed | opened, closed, reopened |
pull_request | a pull request is opened or merged | opened, 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
| Header | Value |
|---|---|
X-Rezee-Event | the event name - push, issues, pull_request |
X-Rezee-Signature-256 | sha256=<hex>, present only when the hook has a secret |
Content-Type | application/json |
User-Agent | Rezee-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
500is 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.
