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.
- Choose Add webhook.
- 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. - Save, then copy the signing secret into your receiver. It is shown only once.
- Choose Manage, then Send signed ping, and check Delivery history to confirm your receiver accepted it.
- 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), 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 for what your receiver gets, and Your app's deliveries for its delivery history.
From the terminal, pass an organization slug or owner/repo as the target:
| 1 | cafe webhook create acme/project --url https://hooks.example.com/gitcafe \ |
| 2 | --event issue.created --event pull.merged |
| 3 | 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 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:
queuedis 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 (poolIdisnull);runnerGroupIdnames the group when only one serves the job and isnullwhen several do. Decide from the job'slabelswhether to start a runner.in_progressfollows when a runner of one of your pools takes it, with that pool and runner, andcompletedwhen it ends. Every job announcedqueuedgets acompleted: a job cancelled or timed out before any runner takes it, or taken by GitCafe's hosted runners after it was announced, sendscompletedwith its conclusion and no pool or runner (a hosted runner taking it sends noin_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 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.
Git events
git.push, git.ref_created and git.ref_deleted carry a git object:
refUpdates: each branch or tag that changed, with itsref,beforeOid,afterOidand whether it wascreated,deletedorforced. At most 100 are listed;refUpdatesCountis the total andrefUpdatesTruncatedistruewhen some were left out.source: what changed the refs.pushis a Git push to GitCafe,mergeandmerge_cleanupa pull request merge and its branch cleanup,restacka stack update,mirrora pull mirror fetching from its upstream, andgithub_synca repository synced with GitHub 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:
- Read
X-GitCafe-Timestampand reject it if it is too old for your liking. - Compute HMAC-SHA256 with your signing secret over the timestamp, a literal
., and the raw request body bytes. - Compare
sha256=<lowercase hex digest>withX-GitCafe-Signatureusing a constant-time comparison.
| 1 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 2 | |
| 3 | function verify(secret, timestamp, body, signature) { |
| 4 | const expected = `sha256=${createHmac("sha256", secret) |
| 5 | .update(`${timestamp}.`) |
| 6 | .update(body) |
| 7 | .digest("hex")}`; |
| 8 | return ( |
| 9 | expected.length === signature.length && |
| 10 | timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) |
| 11 | ); |
| 12 | } |
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.
| 1 | cafe webhook deliveries acme/project <webhook-id> --state failed |
| 2 | cafe webhook redeliver acme/project <webhook-id> <delivery-id> \ |
| 3 | --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.
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.
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), 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) 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.
| 1 | GET /api/orgs/<publisher>/apps/<app>/deliveries?state=failed&installationId=<id> |
| 2 | GET /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/attempts?eventCreatedAt=<timestamp> |
| 3 | GET /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/payload?eventCreatedAt=<timestamp> |
| 4 | POST /api/orgs/<publisher>/apps/<app>/deliveries/<delivery>/redeliver |
| 5 | {"requestId": "retry-1", "eventCreatedAt": "<timestamp>"} |
Organizations that install your app do not see these deliveries (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.