Webhooks
Signed JSON alerts to your own endpoint. The request headers, the payload fields, how to verify the Standard Webhooks signature, retries and idempotency.
A webhook channel POSTs every alert as JSON to an https:// endpoint
you run, signed per Standard Webhooks
so you can verify it came from Norcube Monitor and wasn't altered.
Create a channel
Alert channels → New channel → Kind: Webhook:
- Endpoint URL:
https://only. The host must resolve to a public address and the port must not be one of the refused ports. - Signing secret (optional): your own
whsec_…secret, or leave it blank and one is generated.
The secret is shown once, right after you create the channel. Copy it into your endpoint's configuration. It is never returned again; to get a new one, choose Rotate signing secret in the channel's menu. Your endpoint rejects alerts signed with the new secret until you update it there.
From the CLI, --secret-file saves the generated secret to a file (it
is never printed); see the
reference.
The request
POST /hooks/monitor HTTP/1.1
Content-Type: application/json
User-Agent: NorcubeMonitor-Webhooks/1.0
webhook-id: 7d1c2f7e-6a1f-4f5e-9b8a-1f7c2b3d4e5f
webhook-timestamp: 1762257600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pfHbXo6z5fS2Yw=| Header | Meaning |
|---|---|
webhook-id | The delivery's id. Stays the same across retries, so you can drop a retry you already handled. |
webhook-timestamp | Unix time in seconds when the request was signed. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 of <id>.<timestamp>.<body> under the secret's key. |
The body:
{
"type": "monitor.down",
"timestamp": "2026-11-04T12:00:00Z",
"data": {
"event": "down",
"incidentId": "0c2d4f6a-8b1e-4c3d-9e5f-7a6b5c4d3e2f",
"monitorId": "3f2c8a8e-6c1d-4a59-9a43-2b1f0f6f9d10",
"monitorName": "Shop API",
"monitorType": "http",
"target": "https://shop.example.com/health",
"aspect": "availability",
"severity": "critical",
"condition": "unexpected_status",
"message": "status 503 is not accepted (2xx)",
"title": "Shop API is down",
"facts": {
"statusCode": 503
},
"openedAt": "2026-11-04T11:58:00Z",
"summary": "Shop API is down",
"url": "https://app.norcube.com/acme/monitor/incidents/0c2d4f6a-8b1e-4c3d-9e5f-7a6b5c4d3e2f"
}
}| Field | Meaning |
|---|---|
type | monitor. plus the event: monitor.down, monitor.warning, monitor.expiring, monitor.expired, monitor.recovered, monitor.reminder, monitor.test. |
timestamp | When the request was built, RFC 3339. |
data.event | The event, same as in type. |
data.incidentId | The incident. Absent for a test. |
data.monitorId, monitorName, monitorType, target | The monitor: id, name, http, ssl or domain, and the URL, host:port or domain it checks. |
data.aspect | What the incident is about: availability, certificate or domain. |
data.severity | warning or critical. |
data.condition | The condition code the check found (unexpected_status, cert_expiring, domain_expired, ...). |
data.message | The check's message. |
data.title | The incident's title in the monitor kind's words. |
data.facts | The facts behind the condition, each present only when it applies: expiresAt, daysRemaining, thresholdDays, statusCode, and subject (the host or registrable domain a watch is about). |
data.openedAt, resolvedAt | When the incident opened and, on recovered, when it resolved. RFC 3339. |
data.summary | The alert's subject line, as the email would carry it. |
data.url | A link to the incident in the dashboard. |
Fields that don't apply are left out, so parse defensively. New fields
may be added over time. A test belongs to no monitor: its monitorId is
the all-zero UUID, and its monitorName, monitorType and target are
empty.
Verify the signature
The secret is whsec_ followed by base64. Decode the part after the
prefix to get the key, compute HMAC-SHA256 over the string
<webhook-id>.<webhook-timestamp>.<raw body>, base64 the result and
compare it in constant time with the value after v1,. Read the raw
body bytes before any JSON parsing: a re-serialized body won't match.
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.NORCUBE_MONITOR_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
export function verify(headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
// The header may list several signatures, space separated.
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const given = Buffer.from(value, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}In an Express handler, read the body with express.raw({ type: 'application/json' })
so req.body is the untouched buffer, call verify(req.headers, req.body),
and only then JSON.parse it.
Answer fast, and idempotently
- Answer with any 2xx as soon as you have stored the alert. Do the work afterwards. A send waits at most 15 seconds for your answer.
408,429,5xxand connection failures are retried with backoff (1, 2, 4, ... up to 64 minutes, 8 attempts in all). Any other4xxis final: the delivery is marked failed and shows on the incident's timeline and in the channel's delivery log.- Retries carry the same
webhook-id. Keep the ids you have handled and ignore a repeat. - Redirects are never followed. Point the channel at the final URL.
Use Send test on the channel to see a monitor.test request arrive
and to check your verification code against a real signature.
Alerts
Alert channels (email, webhook, Slack, Discord, SMS), verification, default channels, routes per monitor and event, confirmation and reminders, test sends, and the delivery log with retries.
Incidents
How an incident opens, escalates, reminds and resolves, what its severity and aspect mean, how to acknowledge one, and how to read the incidents page and an incident's timeline.