Alerts

Webhooks

Signed JSON alerts to your own endpoint. The request headers, the payload fields, how to verify the Standard Webhooks signature, retries and idempotency.

Verified November 4, 2026

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=
HeaderMeaning
webhook-idThe delivery's id. Stays the same across retries, so you can drop a retry you already handled.
webhook-timestampUnix time in seconds when the request was signed.
webhook-signaturev1, 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"
  }
}
FieldMeaning
typemonitor. plus the event: monitor.down, monitor.warning, monitor.expiring, monitor.expired, monitor.recovered, monitor.reminder, monitor.test.
timestampWhen the request was built, RFC 3339.
data.eventThe event, same as in type.
data.incidentIdThe incident. Absent for a test.
data.monitorId, monitorName, monitorType, targetThe monitor: id, name, http, ssl or domain, and the URL, host:port or domain it checks.
data.aspectWhat the incident is about: availability, certificate or domain.
data.severitywarning or critical.
data.conditionThe condition code the check found (unexpected_status, cert_expiring, domain_expired, ...).
data.messageThe check's message.
data.titleThe incident's title in the monitor kind's words.
data.factsThe 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, resolvedAtWhen the incident opened and, on recovered, when it resolved. RFC 3339.
data.summaryThe alert's subject line, as the email would carry it.
data.urlA 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, 5xx and connection failures are retried with backoff (1, 2, 4, ... up to 64 minutes, 8 attempts in all). Any other 4xx is 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.

On this page