# Webhooks

Webhooks are in beta: event coverage and payload fields may still change.
A webhook sends a signed HTTPS request to your server when something happens
in a repository or organization, such as a push, a new issue or a merged pull
request.

## Add a webhook

Repository admins open **Settings → Webhooks** in the repository; organization
owners and admins open **Webhooks** in the organization's settings.

1. Choose **Add webhook**.
2. Enter your receiver's HTTPS URL and select events. **All events**
   subscribes to every supported event. To test a new receiver first, select
   only `webhook.ping`.
3. Save, then copy the signing secret into your receiver. It is shown only
   once.
4. Choose **Manage**, then **Send signed ping**, and check **Delivery history**
   to confirm your receiver accepted it.
5. Edit the webhook to add the events you need.

A repository or organization can have up to ten webhooks.

Through the API, use a personal access token. Apps and clients you connected
with OAuth, such as MCP clients and apps acting as you, can list and delete
webhooks but cannot create, edit or re-key one: a webhook keeps sending new
data after you disconnect them.

### Webhooks of installed apps

An app installed in your organization can receive events for the
repositories you selected for it, if its manifest asks for them and you
approved the installation. These webhooks belong to the installation: they do
not appear under **Webhooks**, do not count toward the limit of ten, and you
cannot edit, test or delete them there. Pausing the app stops its deliveries,
resuming starts them again for new events, removing a repository from the app
stops deliveries for that repository, and uninstalling the app removes its
webhook. Deliveries still waiting when you do any of this are not sent, even if
you resume the app or add the repository back. A delivery already being sent
finishes and is recorded with the receiver's answer, but is not retried. The
app is still told that it was paused, resumed, uninstalled or given different
repositories ([installation events](https://mn.staging.git.cafe/docs/integrations/webhooks.md#installation-events)), so it can stop
work and drop credentials; these events name no repository it can no longer
read.

If you publish an app, see [App webhooks](https://mn.staging.git.cafe/docs/integrations/webhooks.md#app-webhooks) for what your
receiver gets, and [Your app's deliveries](https://mn.staging.git.cafe/docs/integrations/webhooks.md#your-apps-deliveries) for its
delivery history.

From the terminal, pass an organization slug or `owner/repo` as the target:

```sh
cafe webhook create acme/project --url https://hooks.example.com/gitcafe \
  --event issue.created --event pull.merged
cafe webhook ping acme/project <webhook-id> --request-id first-ping --expected-version <version>
```

`cafe webhook capabilities` lists the supported events, and
`cafe webhook readiness` checks whether a webhook is ready to send.

## Events

| Events                                                                       | Sent when                                            |
| ---------------------------------------------------------------------------- | ---------------------------------------------------- |
| `git.push`                                                                   | Branches or tags change by push, merge or sync       |
| `git.ref_created`, `git.ref_deleted`                                         | Branches or tags are created or deleted              |
| `repo.default_branch_changed`                                                | The default branch changes                           |
| `issue.created`, `issue.updated`, `issue.closed`, `issue.reopened`           | An issue is created, edited, closed or reopened      |
| `issue.locked`, `issue.unlocked`, `issue.pinned`, `issue.unpinned`           | An issue is locked, unlocked, pinned or unpinned     |
| `issue.assignees_updated`, `issue.labels_updated`, `issue.milestone_updated` | An issue's assignees, labels or milestone change     |
| `issue.linked`, `issue.unlinked`                                             | A pull request is linked to or unlinked from it      |
| `issue.comment_created`, `issue.comment_updated`, `issue.comment_deleted`    | An issue comment changes                             |
| `issue.reaction_added`, `issue.reaction_removed`                             | A reaction is added or removed                       |
| `pull.created`, `pull.updated`, `pull.closed`, `pull.reopened`               | A pull request is opened, edited, closed or reopened |
| `pull.synchronized`                                                          | A pull request's source branch gets new commits      |
| `pull.merged`                                                                | A pull request merges in GitCafe                     |
| `pull.drafted`, `pull.ready`, `pull.locked`, `pull.unlocked`                 | Draft or lock state changes                          |
| `pull.reviewers_updated`, `pull.assignees_updated`                           | Reviewers or assignees change                        |
| `pull.labels_updated`, `pull.milestone_updated`                              | Labels or milestone change                           |
| `pull.linked`, `pull.unlinked`                                               | An issue is linked or unlinked                       |
| `pull.review_submitted`, `pull.review_dismissed`                             | A review is submitted or dismissed                   |
| `pull.comment_created`, `pull.comment_updated`, `pull.comment_deleted`       | A pull request comment changes                       |
| `pull.comment_hidden`, `pull.comment_unhidden`                               | A comment is hidden or shown                         |
| `pull.comment_resolved`, `pull.comment_unresolved`                           | A review thread is resolved or reopened              |
| `pull.reaction_added`, `pull.reaction_removed`                               | A reaction is added or removed                       |
| `label.created`, `label.updated`, `label.deleted`                            | A repository label changes                           |
| `milestone.created`, `milestone.updated`, `milestone.deleted`                | A milestone changes                                  |
| `ci.run_updated`                                                             | A [CI](https://mn.staging.git.cafe/docs/ci/checks-and-logs.md) run's state changes |
| `actions.workflow_job`                                                       | An Actions job on your organization's pool changes   |
| `webhook.ping`                                                               | You send a test ping                                 |

Events describe changes made in GitCafe. Issues and pull requests imported
from GitHub do not send `issue.*` or `pull.*` events, and past activity is not
sent when you add a webhook.

### Actions job events

`actions.workflow_job` tells an autoscaler when to start and stop runners, as
GitHub's `workflow_job` does. It is sent for a job that your organization's
runner groups serve, with an `action` of `queued`, `in_progress` (a runner
took the job) or `completed`:

- `queued` is sent when the job is queued and at least one enabled runner
  group of the repository's organization may run it. The job has no pool
  yet (`poolId` is `null`); `runnerGroupId` names the group when only one
  serves the job and is `null` when several do. Decide from the job's
  `labels` whether to start a runner.
- `in_progress` follows when a runner of one of your pools takes it, with
  that pool and runner, and `completed` when it ends. Every job announced
  `queued` gets a `completed`: a job cancelled or timed out before any runner
  takes it, or taken by GitCafe's hosted runners after it was announced,
  sends `completed` with its conclusion and no pool or runner (a hosted
  runner taking it sends no `in_progress`).
- A job no runner group of your organization serves sends nothing, and so
  does a job on, or served by, GitCafe's hosted runners.

Each action is captured once per job, in order, and a receiver is never
sent `in_progress` or `completed` for a job it was not sent `queued` for:
when a runner of your pool takes a job that was not announced to you
(GitCafe's hosted runners also served it, or your group was created, bound
or enabled after it was queued), `queued` is captured first. Deliveries may
still arrive out of order, because a retried delivery waits for its next
attempt while later events go out: a retried `queued` can arrive after its
`in_progress`. Order a job's events by `status` and the `queuedAt`,
`startedAt` and `completedAt` timestamps, as on GitHub. A job whose
repository is transferred to another organization while it runs sends no
`completed` to the old organization; reconcile it with the demand read.
`workflowJob` holds the job and
run IDs, the pool and runner IDs once a runner took the job, a runner group
number (`runnerGroupId`, the `runner_group_id` of `generate-jitconfig`), the
run attempt, the job name, its labels (as in `runs-on`, lower-cased), its
`status` (the same as `action`), its `conclusion` once completed, and when
it was queued, started and completed. It never contains scripts,
environment variables, secrets, tokens, clone URLs or logs, and has no
`actor`. The shape follows GitHub's `workflow_job` event but is not
compatible with it. A receiver that missed deliveries reconciles with the
pool's provider demand read.

The body changed when `queued` was added: `workflowJob` gained
`runnerGroupId`, and `poolId` is now `null` on `queued` and on a `completed`
no runner of your pools took. A decoder that rejects unknown fields or a
`null` `poolId` must accept both.

An [installed app](https://mn.staging.git.cafe/docs/integrations/webhooks.md#app-webhooks) receives these events when it has the
**Runners** permission (`org:runners`, read or write), only for jobs on its
installing organization's own pools.

## The request

Each delivery is an HTTPS `POST` with a JSON body. The body has an event `id`,
a `type`, a timestamp, the repository (its ID, its organization's ID, and its
`owner` and `name` when the event happened, as in `/api/repos/<owner>/<name>`),
the `actor` who made the change, and a snapshot of the resource after the
change. Edit events include the fields that changed. Text bodies longer than
65,536 characters are cut and flagged as truncated.

| Header                  | Contains                                      |
| ----------------------- | --------------------------------------------- |
| `X-GitCafe-Event`       | The event type, same as the body's `type`     |
| `X-GitCafe-Delivery`    | The delivery ID, the same across retries      |
| `X-GitCafe-Timestamp`   | When this attempt was signed, in Unix seconds |
| `X-GitCafe-Key-Version` | Which signing secret was used                 |
| `X-GitCafe-Signature`   | `sha256=` followed by the hex HMAC            |

The full payload schemas are in the `webhooks` section of the
[OpenAPI description](https://mn.staging.git.cafe/api/openapi).

### Git events

`git.push`, `git.ref_created` and `git.ref_deleted` carry a `git` object:

- `refUpdates`: each branch or tag that changed, with its `ref`,
  `beforeOid`, `afterOid` and whether it was `created`, `deleted` or
  `forced`. At most 100 are listed; `refUpdatesCount` is the total and
  `refUpdatesTruncated` is `true` when some were left out.
- `source`: what changed the refs. `push` is a Git push to GitCafe,
  `merge` and `merge_cleanup` a pull request merge and its branch cleanup,
  `restack` a stack update, `mirror` a pull mirror fetching from its
  upstream, and `github_sync` a repository [synced with GitHub](https://mn.staging.git.cafe/docs/integrations/github-sync.md)
  importing GitHub's refs.

A CI service should not start builds for `github_sync` (or `mirror`) pushes:
for a synced repository, GitHub remains where CI runs on those changes, and
GitCafe Actions does not run for them either.

## Verify the signature

Before you trust a request:

1. Read `X-GitCafe-Timestamp` and reject it if it is too old for your liking.
2. Compute HMAC-SHA256 with your signing secret over the timestamp, a literal
   `.`, and the raw request body bytes.
3. Compare `sha256=<lowercase hex digest>` with `X-GitCafe-Signature` using a
   constant-time comparison.

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

function verify(secret, timestamp, body, signature) {
  const expected = `sha256=${createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex")}`;
  return (
    expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}
```

## Respond and handle retries

Return any `2xx` status once you have stored the event. GitCafe retries on
network errors, timeouts (10 seconds), `408`, `425`, `429` and `5xx`, honouring
`Retry-After` up to an hour, for up to eight attempts within 24 hours. Other
statuses and redirects end the delivery.

Deliveries can arrive more than once and in any order. Deduplicate by the
body's event `id`.

## Inspect and redeliver

Under **Manage**, **Delivery history** shows each delivery's state, attempts
and HTTP status; **View attempts** and **View payload** show the details.
For a delivery that finally failed, fix your receiver, then choose **Request
redelivery** to send the same event again. Each event can be redelivered twice,
within 30 days.

```sh
cafe webhook deliveries acme/project <webhook-id> --state failed
cafe webhook redeliver acme/project <webhook-id> <delivery-id> \
  --request-id retry-1 --expected-version <version> --event-created-at <timestamp>
```

Delivery history is kept for 30 days.

## Rotate the signing secret

Choose **Rotate signing secret** under **More actions**, or run
`cafe webhook rotate-secret <target> <webhook-id> --yes`. The new secret is
shown once. From then on every attempt, including retries of earlier events,
is signed with the new secret, so update your receiver straight away.
`X-GitCafe-Key-Version` tells you which secret signed a request.

## App webhooks

An app that lists `capabilities.webhooks` (an HTTPS `url` and the `events` it
needs) in its manifest receives those events for every repository each
installation selected. Each event needs read access to its payload in the
manifest's `permissions.autonomous`; registration refuses one that lacks it.
Label and milestone events need repository **Metadata** (`repo:metadata`),
and reaction events need comments too, since a reaction can be on a comment.
`actions.workflow_job` needs the organization **Runners** permission
(`org:runners`) and is sent only for jobs on the installing organization's own
pools; a runner service uses it with
[`generate-jitconfig`](https://mn.staging.git.cafe/docs/ci/runner-providers.md#build-a-runner-service-as-an-app).
Apps cannot subscribe to all events with `*` or to `webhook.ping`.

The signing secret belongs to the app registration: it is shown once when you
register the app, and
`POST /api/orgs/<publisher>/apps/<app>/webhook-secret/rotate` replaces it for
every installation at once and shows the new one once. Requests are signed and
verified as above, and `X-GitCafe-Key-Version` names the secret version. The
body also contains `installation: { "id": ... }`; deduplicate by the
installation and event `id` together.

An app receives what its bot could read: the `body` of a hidden or deleted pull
request comment, and of a deleted issue comment, is `null`. Events your app's
own bot causes are not sent to that installation.

Apps also have events about their own bot, sent only to the installation
whose bot is targeted and never to other webhooks. Each names the bot as
`bot: { "id": ... }`.

| Events                                       | Sent when                                         | Needs read access to     |
| -------------------------------------------- | ------------------------------------------------- | ------------------------ |
| `issue.bot_assigned`, `issue.bot_unassigned` | Someone assigns or unassigns the bot on an issue  | Issues                   |
| `pull.bot_assigned`, `pull.bot_unassigned`   | Someone assigns or unassigns it on a pull request | Pull requests            |
| `pull.bot_review_requested`                  | Someone requests the bot's review                 | Pull requests            |
| `issue.bot_mentioned`, `pull.bot_mentioned`  | A new comment `@mentions` the bot                 | The subject and comments |
| `check.rerequested`                          | Someone asks to re-run a check the app reported   | Checks                   |

`check.rerequested` names the app's bot and carries `checkRun`: the check's
`id`, `key`, `headOid`, `name`, `externalId`, `detailsUrl` and the
`conclusion` being re-run. See [Report checks from your CI](https://mn.staging.git.cafe/docs/integrations/check-runs.md#re-run-a-check).

Mentions count in new comments, inline comments and replies, outside code, up
to 20 handles a comment; edits, review summaries and descriptions send
nothing. The bot must be mentionable (`capabilities.bot.mentionable`).

### Installation events

An app with `capabilities.webhooks` also hears when an organization admin
installs or changes its installation, whether or not `events` lists these. They go only to
that installation's webhook, need no permission, and name the installation and
its organization as `installation: { "id": ... }` and
`organization: { "id": ... }`, plus the admin as `actor`.

| Event                               | Sent when                                        |
| ----------------------------------- | ------------------------------------------------ |
| `installation.created`              | The app is installed and the installation active |
| `installation.paused`               | The installation is paused                       |
| `installation.resumed`              | It is resumed                                    |
| `installation.uninstalled`          | The app is uninstalled                           |
| `installation.permissions_accepted` | A newer revision of the app is approved          |
| `installation.repositories_updated` | The repositories the installation reaches change |

`installation.created` is sent once per installation, when an admin completes
the install. It has `repositorySelection` and, for selected repositories,
`repositories`: each one's `id`, `owner` and `name`, up to 50 (the most an
installation selects; `repositoriesTruncated` would say more were selected).
For all repositories it names none. Like `repositoriesAdded`, it names only
the repositories the installation may still read when it is sent: one removed
before then, or every one if the installation was paused, is left out, and the
event is still sent. Read the installation
(`GET /api/apps/installations/<id>`) for its current repositories. Uninstalling
and installing the app again is a new installation, with a new ID, and sends
`installation.created` again.

`installation.permissions_accepted` is sent once when an admin approves a newer
revision of the app (GitHub's `new_permissions_accepted`); reviewing an update
without approving it, or approving the revision the installation already runs,
sends nothing. It has the adopted `revision` and the permissions the
installation now holds, as `GET /api/apps/installations/<id>` shows them:
`permissions` for the bot, and `user` with `permissions` and `audiences` for
people who connect the app. It names no repository. Approving stops every token
the app holds, so this is the app's cue to request new ones; it is sent to the
webhook the new revision names, and arrives even though the app's old tokens
no longer work.

Pausing and uninstalling still drop every other delivery that was waiting
(see [Webhooks of installed apps](https://mn.staging.git.cafe/docs/integrations/webhooks.md#webhooks-of-installed-apps)), but not these
events: `installation.paused` and `installation.uninstalled` are sent, and
retried, after the app has lost access. They carry no repository. After
`installation.uninstalled` the installation sends nothing more; drop its
credentials and stop its work. After `installation.resumed`, events from while
it was paused are not sent: read the installation
(`GET /api/apps/installations/<id>`) to reconcile.

`installation.repositories_updated` has `repositorySelection` (`all` or
`selected`) and `previousRepositorySelection`. Between two selections,
`repositoriesAdded` names each repository the installation gained (`id`,
`owner` and `name`) and `repositoriesRemoved` lists only the `id` of each one
it lost; `repositoriesRemovedTruncated` is `true` if more than 100 were removed.
A change to or from all repositories lists neither: read the installation for
its selection. `repositoriesAdded` names only the repositories the installation
may still read when the event is sent: one removed again before then, or every
one if the installation was paused, is left out, and the event is still sent,
unless that leaves it with nothing added, nothing removed and the same
selection. So `repositoriesRemoved` can list, by ID only, a repository the app
never received, such as one added and removed again while the installation was
paused. A repository your bot creates joins the selection without an event.

Only an organization admin's changes send these events. GitCafe suspending your
app sends none, and none is sent while it is suspended: the events waiting when
it was suspended, and the changes admins make during it, are not sent after
GitCafe lifts the suspension. Once it is lifted, read each installation
(`GET /api/apps/installations/<id>`) to reconcile. Nor does a revision you
publish send one: what it removes leaves every installation at once, and
`installation.permissions_accepted` follows only when an admin approves it.
Until then, installation events still go to the webhook the installation
approved, even if the new revision lists none of its `events` or has no
`capabilities.webhooks`. Once approved, a new webhook URL gets only events from
then on (installation events waiting for the old one are not sent), and a
revision without `capabilities.webhooks` leaves the installation no webhook, so
nothing more is sent to it, not even the `installation.permissions_accepted`
for that approval: the revision removed its URL, and the old one gets nothing.

Installation events are not ordered with each other or with other events: a
retried event can arrive after a later one. Use `createdAt`, or read the
installation, when the order matters.

### Your app's deliveries

Owners and admins of the organization that publishes an app see its deliveries
under **Settings → Apps**: edit the app and open **Webhook deliveries**. The
list covers every installation, newest first, with the event, the installation
and its organization, the state, the attempts, the last HTTP status and error,
and when the event happened. **View attempts** shows each attempt's status and
duration, and **View payload** the body your receiver was last sent, exactly as
it was signed. Signing headers and your receiver's response bodies are never
shown. Only deliveries GitCafe has tried to send are listed, and the
redeliveries you request, so an event dropped before its first attempt (when
the app was paused, or a repository removed) does not appear. History is kept
for 30 days, also after an organization uninstalls your app: its organization
is named by its handle while your app is installed there, and by its ID only
once it is uninstalled.

**Redeliver** sends an event again, under the same limits as other webhooks:
twice per event, one at a time, within 30 days. It goes only to the URL the
event was first sent to, and only while the installation may still receive
the event, so events from a repository the organization removed from your app
are not sent again. The installation's own events
([installation events](https://mn.staging.git.cafe/docs/integrations/webhooks.md#installation-events)) can be sent again while GitCafe
has not suspended your app. Once an organization uninstalls your app, nothing
of that installation can be redelivered, its own events included, though its
history stays listed.

```sh
GET  /api/orgs/<publisher>/apps/<app>/deliveries?state=failed&installationId=<id>
GET  /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/attempts?eventCreatedAt=<timestamp>
GET  /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/payload?eventCreatedAt=<timestamp>
POST /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/redeliver
     {"requestId": "retry-1", "eventCreatedAt": "<timestamp>"}
```

Organizations that install your app do not see these deliveries
([Webhooks of installed apps](https://mn.staging.git.cafe/docs/integrations/webhooks.md#webhooks-of-installed-apps)).

## Stop a webhook

Disable it to pause deliveries, or delete it under **More actions**. Both stop
pending deliveries. Changing a repository's visibility or removing the person
who created a webhook does not remove it; webhooks can carry private
repository data, so review them when access changes.
