The request
- A
POSTto the URL you give, which must start withhttps://and have a public address (not a private or local network). Content-Type: application/json, with the alert as the body.X-BBoxProbe-Signature: sha256=followed by the HMAC-SHA256 of the raw body, in hex, keyed with the channel's signing secret.
Answer with any 2xx status within 10 seconds. Anything else counts as failed, and the alert is tried again 1, 5 and 15 minutes later, then sent to your account email instead.
The payload
This is the body of a down alert, shown with line breaks; the real body is compact JSON on one line. A closing
notice has "status": "resolved" and an ended_at.
{
"monitor": "ct3skrhd11sm",
"name": "Website",
"target": "https://example.com/",
"current_target": "https://example.com/",
"alert": "MonitorDown",
"status": "firing",
"started_at": "2026-10-27T10:32:00Z",
"ended_at": null,
"failing_cities": [
"fra",
"iad"
],
"summary": "Website is down",
"details": "Frankfurt and Virginia get 502 Bad Gateway.\nDown since 10:32 UTC.\nAnother notice follows when it's back.",
"facts": {
"held_while_unstable": false,
"cities": [
{
"city": "fra",
"name": "Frankfurt",
"problem": "gets 502 Bad Gateway",
"http_status": 502,
"seconds": 0.21
},
{
"city": "iad",
"name": "Virginia",
"problem": "gets 502 Bad Gateway",
"http_status": 502,
"seconds": 0.18
}
]
},
"test": false
}
| Field | What it holds |
|---|---|
monitor | The monitor's id, as in its page's address. |
name | The monitor's name. |
target | The address the alert is about: for a closing notice, the one it had when the outage began. |
current_target | The monitor's address now; differs from target when it was edited since. |
alert | Which alert: MonitorDown, MonitorFlapping, AvailabilityBurnFast, AvailabilityBurnSlow, LatencyDegraded, CertExpiring, CertExpiringSoon, or Test. |
status | firing or resolved. |
started_at, ended_at | When it began and ended, ISO 8601 in UTC; ended_at is null while it goes on. |
failing_cities | While a down alert fires, the codes of the cities that fail; else empty. |
summary | The alert's title, as the email's subject says it. |
details | The alert's text: the facts, what it means and what to do, lines joined by \n. |
facts | The numbers behind it, by alert (below). A fact that isn't known is null. |
test | true on a test from Send a test, which has "alert": "Test" and no monitor. |
The facts, by alert
| Alert | Facts |
|---|---|
MonitorDown | Firing: cities, each with city, name, problem, http_status and seconds; held_while_unstable. Resolved: down_seconds. |
MonitorFlapping | Firing: times_down_2h. Resolved: unstable_seconds, still_down, down_notice_at. |
AvailabilityBurnFast, AvailabilityBurnSlow | window ("1h", "6h"), availability, objective, budget_minutes, budget_used, budget_alert_above, budget_left. Shares are fractions of 1. |
LatencyDegraded | window ("30m" firing, "5m" resolved), threshold_ms, slow_share; firing also slow_cities, each with city, name, seconds. |
CertExpiring, CertExpiringSoon | hostname, expires_at, days_left. |
This is the current format. Read the fields you need and ignore the others, so a field added later doesn't break your code.
Verify the signature
When you add the channel, the app shows its signing secret once; later, Reveal on the channel's
card shows it again. Compute the HMAC-SHA256 of the raw request body with that secret, put sha256=
in front, and compare it with the X-BBoxProbe-Signature header in constant time. Ignore any request where
the two differ. Compute it over the bytes exactly as they arrived: parsing the JSON and encoding it again
changes them.
Ruby
# Rails: request.raw_post is the body exactly as it arrived
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
Rack::Utils.secure_compare(expected, request.headers["X-BBoxProbe-Signature"].to_s)
Node
// Express: express.raw({ type: "application/json" }) keeps the body as bytes
const crypto = require("node:crypto")
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex"))
const given = Buffer.from(req.get("X-BBoxProbe-Signature") || "")
const valid = given.length === expected.length && crypto.timingSafeEqual(expected, given)
Python
import hashlib, hmac # body: the raw request body as bytes; signature_header: the header's value expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() valid = hmac.compare_digest(expected, signature_header)
Set it up
- In BBoxProbe, open Alert channels and, under Add an alert channel, pick Webhook as the kind.
- Give it a Name, such as "On-call", and paste the URL into Webhook URL (https://, a public address).
- Press Add channel, then Send a test on its card. The test arrives as "Test from BBoxProbe: your webhook channel 'On-call' works".
- Alerts now go there from every monitor that hasn't picked its own channels. To send only some monitors' alerts there, tick it under Alert channels in those monitors' settings.
Each notice is sent once per channel. In rare failures one can arrive twice; the monitor, alert,
status and started_at together name it, if you want to drop a repeat.
When your endpoint can't take it
Each alert is posted as it happens, and its closing notice when it ends. When your endpoint doesn't answer within 10 seconds or answers with an error, the alert is tried again 1, 5 and 15 minutes later. After the last try it goes to your account email instead, saying which channel couldn't take it and why. The channels page lists the last 50 alerts and tests sent, with each channel's result.
The number of alert channels depends on the plan: 1 on Free, 3 on Pro, and 10 on Max. Every kind of channel is on every plan.