Skip to content
Rowsafe
Docs

Webhooks

The webhook payload, headers, retries and signature check.

A webhook channel sends each alert as a signed JSON POST to your https URL:

rowsafe channels add --type webhook --name pager --url https://hooks.example.com/rowsafe

It prints the signing secret (whsec_...) once. Store it for your receiver.

Request

POST with Content-Type: application/json, User-Agent: Rowsafe-Notifications/1, and:

Header
X-Rowsafe-Eventalert.firing, alert.resolved, alert.reminder or test
X-Rowsafe-DeliveryUnique delivery ID (ntf_...), the same on retries.
X-Rowsafe-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Payload

{
  "version": 1,
  "event": "alert.firing",
  "delivery_id": "ntf_5k2m9x7q4t1v0",
  "sent_at": "2026-09-24T03:12:00Z",
  "org_id": "org_0123456789abc",
  "alert": {
    "id": "alr_7h3k2m9x7q4t1",
    "rule": "disk_free_critical",
    "severity": "critical",
    "state": "firing",
    "database": { "id": "db_1a2b3c4d5e6f7", "name": "app" },
    "host": { "id": "host_9h8g7f6e5d4c3", "hostname": "db-1" },
    "summary": "app: only 4.2% disk space left on db-1 (1.9 GiB free)",
    "description": "When the data directory's filesystem fills up, PostgreSQL stops accepting writes and may shut down. ...",
    "next_step": "On db-1: df -h and du -sh the data directory's pg_wal; rowsafe status app shows whether continuous backup is failing",
    "value": 4.2,
    "threshold": 5,
    "unit": "%",
    "url": "https://app.rowsafe.sh/databases/app",
    "started_at": "2026-09-24T03:05:00Z"
  }
}
Field
versionPayload version, 1.
eventSame as X-Rowsafe-Event.
alert.ruleOne of the alert rules.
alert.severitycritical, warning or info.
alert.statefiring or resolved.
alert.database, alert.hostThe target, when there is one. A host alert has no database.
alert.value, threshold, unitFor rules with a threshold.
alert.resolved_at, acknowledged_at, acknowledged_byPresent once they apply.

Empty optional fields are left out. Ignore fields you don't know.

Delivery

  • Answer 2xx within 10 seconds. Anything else, redirects included, is a failure.
  • Failures are retried up to 8 times, waiting 1, 2, 4, 8, 16, 32 and 64 minutes (about 2 hours).
  • Drop duplicates by X-Rowsafe-Delivery.
  • Public https only, no credentials in the URL. Private and internal addresses are refused, also after DNS resolution.
  • rowsafe channels test ID sends a test event now.

Verify the signature

HMAC-SHA256 keyed with the whole whsec_... secret, over <timestamp>.<raw body>. Reject timestamps more than 5 minutes off (replays). Compare in constant time.

const crypto = require("node:crypto");

function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Use the body exactly as received: re-serialized JSON breaks the signature.

Slack, Discord and email

These get a readable message instead of JSON: state, severity, summary, description, target, value and threshold, since when, next step, and a dashboard link. Discord messages never mention anyone. Reminder emails say how to acknowledge.

Microsoft Teams, Telegram and ntfy get the same readable message. PagerDuty and Opsgenie get one incident per alert: a reminder adds to it, and it resolves (or closes) when the alert does. Each test is its own incident. Channel types.

Edit on GitHub