Skip to content

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.

kindwhere it goes
Webhookan HTTPS URL of yours, signed so you can check it came from us
Emailan email address
Slacka Slack incoming-webhook URL (https://hooks.slack.com/services/…), posted as Block Kit
Microsoft Teamsthe 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.

eventwhen it is sent
ci.job_completeda 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_faileda 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_refuseda 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_changedsticky disks were requested or the request withdrawn
ci.static_ip_request_changeda static egress IP was requested or the request withdrawn
billing.spend_alert_crossedthe month's spend passed one of your spending alerts
billing.dunning_stage_changeda 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:

json
{
  "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:

js
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.