Documentation · Observability
Notifications
Send CI jobs, billing changes and sticky-disk events to a signed webhook, an email address, Slack or Microsoft Teams, and check a webhook came from us.
Send CI jobs, billing changes and sticky-disk events to a webhook, an email address, Slack or Microsoft Teams, so nobody has to watch the dashboard for them.
Notifications for infrastructure runs are separate, and configured over the Terraform API: see notifications for infrastructure runs.
Under Settings → Notifications, an owner or an admin chooses the kind of channel, where it goes and which events it receives, and presses Add channel. A channel can be turned off and on again without losing it.
| kind | where it goes |
|---|---|
| Webhook | an HTTPS URL of yours, signed so you can check it came from us |
| an email address | |
| Slack | a Slack incoming-webhook URL (https://hooks.slack.com/services/…), posted as Block Kit |
| Microsoft Teams | the URL from a Teams Workflow, "Post to a channel when a webhook request is received", posted as an Adaptive Card |
A Slack or Teams URL is a credential, so once it is saved it is only ever shown masked.
| event | when it is sent |
|---|---|
ci.job_completed | a CI job ran to its end on our side, whatever the build's own result: a job whose tests fail is completed here, and GitHub's check has the result |
ci.job_failed | a CI job could not run to its end because of something on our side: its machine stopped, it ran past the time a job may run, or it never started |
ci.sticky_disk_credential_refused | a credential was found in what a build wrote to a sticky disk, so nothing it wrote was kept; once per repository until a build's changes are kept or the disk is reset |
ci.sticky_disk_request_changed | sticky disks were requested or the request withdrawn |
ci.static_ip_request_changed | a static egress IP was requested or the request withdrawn |
billing.spend_alert_crossed | the month's spend passed one of your spending alerts |
billing.dunning_stage_changed | a failed payment reached a stage: past due, grace, degraded or suspended |
A cancelled job sends neither. The dashboard lists the same events by name, such as A CI job failed on our side, when you choose them for a channel.
A webhook delivery is a POST of JSON to the URL you saved:
{
"event": "ci.job_failed",
"organization_id": "…",
"occurred_at": "2026-10-08T12:00:00.000Z",
"data": {}
}Two headers come with it. X-Runners-Event is the event's name, and X-Runners-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the channel's signing secret as text (not hex-decoded). The dashboard shows that secret once, when you add the channel; copy it then.
To check a delivery came from us, compute the same HMAC over the body exactly as it arrived, before parsing it, and compare it with the header in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
function fromRunners(rawBody, header, secret) {
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"));
const given = Buffer.from(header ?? "");
return given.length === expected.length && timingSafeEqual(given, expected);
}A delivery that fails is tried once more, a second or two later, and then given up. Webhook deliveries do not follow redirects. We send to exactly the URL you saved; if it answers with any 3xx, the delivery counts as failed and we do not request the address it points to. Give us the final URL, including the scheme and any trailing slash your server insists on.