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/rowsafeIt 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-Event | alert.firing, alert.resolved, alert.reminder or test |
X-Rowsafe-Delivery | Unique delivery ID (ntf_...), the same on retries. |
X-Rowsafe-Signature | t=<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 | |
|---|---|
version | Payload version, 1. |
event | Same as X-Rowsafe-Event. |
alert.rule | One of the alert rules. |
alert.severity | critical, warning or info. |
alert.state | firing or resolved. |
alert.database, alert.host | The target, when there is one. A host alert has no database. |
alert.value, threshold, unit | For rules with a threshold. |
alert.resolved_at, acknowledged_at, acknowledged_by | Present 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
httpsonly, no credentials in the URL. Private and internal addresses are refused, also after DNS resolution. rowsafe channels test IDsends atestevent 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.