Webhooks
What saysOK sends to a webhook endpoint, and how to check that a request really came from saysOK.
Reference · Updated Oct 24, 2026
A webhook sends an HTTPS POST with a JSON body to your endpoint whenever saysOK notifies it. There are two kinds:
- A workspace's webhook channel. Added under Integrations, Webhooks, with the endpoint's URL and an optional signing secret. Alert rules and incidents notify it like any other channel: an incident or alert opens, changes or resolves, the weekly report goes out, or someone presses Send test.
- A status page's webhook subscriber. Added by an admin under the status page's subscribers. It receives the page's public notices: incidents shown on the page, and maintenance windows starting and ending. These requests are not signed.
The request
POST /hooks/saysok HTTP/1.1
Content-Type: application/json
User-Agent: saysok/1.0 (+https://saysok.com/docs/webhooks)
X-Saysok-Event: incident_open
X-Saysok-Delivery-Id: del_3b9e7f0c
X-Saysok-Workspace: acme
X-Saysok-Signature: t=1792834867,v1=5f0c1e…
| Header | What it holds |
|---|---|
X-Saysok-Event | The event, the same as event in the body. |
X-Saysok-Delivery-Id | The same for every attempt of one delivery. Use it to ignore a retry you already handled. |
X-Saysok-Workspace | The workspace's slug, as in its addresses. |
X-Saysok-Signature | Only when the channel has a signing secret. See Checking the signature. |
The body
Every body has the same five fields, then one object that depends on the event:
{
"version": "2025-05-01",
"event": "incident_open",
"deliveryId": "del_3b9e7f0c",
"occurredAt": "2026-10-24T09:41:07.000Z",
"workspace": { "id": "ws_6f1c2d", "slug": "acme" },
"incident": {
"id": "INC-2843",
"shortId": "INC-2843",
"status": "investigating",
"sev": "SEV-1",
"title": "Checkout API: no answer within 10 seconds",
"monitor": { "id": "mon_8a2b", "name": "Checkout API" },
"regions": ["iad", "fra"],
"openedAt": "2026-10-24T09:41:05.000Z",
"url": "https://saysok.com/app/incidents/INC-2843?ws=acme",
"message": "Checkout API didn't answer from Virginia and Frankfurt. The request timed out after 10 s.\nIt last answered normally at 11:58 (4 minutes ago).\nA deploy finished at 11:57.\nsaysOK checks again every minute.",
"explanation": {
"v": 1,
"headline": "Checkout API: no answer within 10 seconds",
"why": "Checkout API didn't answer from Virginia and Frankfurt. The request timed out after 10 s.",
"evidence": ["It last answered normally at 11:58 (4 minutes ago).", "A deploy finished at 11:57."],
"next": "saysOK checks again every minute.",
"short": "Checkout API: no answer within 10 seconds. Fails in 2 of 3 regions, since a deploy at 11:57.",
"facts": { "kind": "monitor", "failure": "timeout", "regionsFailing": ["iad", "fra"] }
}
}
}
version changes only when a field is removed or changes meaning. New fields can appear at any time, so ignore the ones you do not know. A field with no value is left out rather than sent as null, except incident.monitor for an incident no monitor opened.
Events
event | Sent when | Object |
|---|---|---|
incident_open | An incident opens, or an alert rule fires | incident |
incident_update | An incident's status or details change | incident |
incident_resolved | It resolves, or the alert rule stops matching | incident |
alert_test | Someone presses Send test on the channel or on an alert rule | alert |
weekly_report | The weekly report goes out, on a channel that has it turned on | report |
maintenance_starting | A maintenance window starts (status page subscribers only) | maintenance |
maintenance_ended | It ends (status page subscribers only) | maintenance |
When you save the channel, saysOK also sends one webhook.ping with "ping": { "ok": true } to check that the endpoint answers. Answer it with any 2xx.
The incident
| Field | |
|---|---|
id | INC-… for an incident. An alert rule that alerts without opening an incident uses alert:<id> instead, the same for its open and its resolve, so you can pair them. |
shortId | INC-…. Missing for an alert without an incident. |
status | investigating, identified, monitoring or resolved. |
sev | SEV-1 to SEV-3. Missing for a SEV-4 alert. |
title | What a person reads first. |
monitor | { id, name } of the monitor, heartbeat or browser check that alerted. |
regions | Probe regions that saw the failure, such as iad, fra, sin. |
openedAt, resolvedAt | ISO 8601 times in UTC. |
url | Opens the incident in saysOK (the monitor, for an alert without an incident). |
message | The text the other channels show, one line per fact. |
explanation | Present when saysOK could explain the alert. The same sentences as message, split up, and facts, the values they were written from. Times in the sentences are in the workspace's time zone. |
alert and report carry the same title and message as the other channels, and alert.meta.test is always true. Their other fields can change, so do not build on them.
From a status page
A status page's subscriber gets only what the page shows the public: incident or maintenance with title, message and url (the status page's address), and status for an incident. There are no ids, monitors, regions or explanations.
{
"version": "2025-05-01",
"event": "incident_update",
"deliveryId": "del_9a41d2e7",
"occurredAt": "2026-10-24T10:02:31.000Z",
"workspace": { "id": "ws_6f1c2d", "slug": "acme" },
"incident": {
"status": "identified",
"title": "Partial outage: Checkout",
"monitor": null,
"regions": [],
"url": "https://status.acme.com",
"message": "We found the cause and are rolling out a fix."
}
}
Checking the signature
With a signing secret on the channel, every request carries:
X-Saysok-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
v1 is the HMAC-SHA256, with your secret as the key, of the timestamp, a full stop, and the raw body exactly as it arrived: <t>.<body>. To check a request:
- Read the raw body before any JSON parser touches it. Parsing and serializing again changes the bytes.
- Split the header on
,and each part on the first=to gettandv1. - Compute the HMAC of
t + "." + bodyand compare it withv1in constant time. - Refuse the request when
tis more than five minutes from your clock, so a recorded request cannot be replayed later.
Each attempt is signed again with a new t, so a retry passes this check too.
Node.js:
const crypto = require("node:crypto");
function verifySaysokSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => {
const at = part.indexOf("=");
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
}),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
With Express, take the raw body from express.raw({ type: "application/json" }) and pass req.body (a Buffer) and req.get("X-Saysok-Signature").
Python:
import hashlib
import hmac
import time
def verify_saysok_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance_seconds or "v1" not in parts:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
With Flask, pass request.get_data() and request.headers.get("X-Saysok-Signature").
Answering, and retries
Answer with any 2xx within 8 seconds. saysOK reads nothing from the body.
| Your endpoint | What saysOK does |
|---|---|
| 2xx | Delivered. |
| 408, 429, 5xx, no answer in 8 seconds, connection error | Tries again after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. A Retry-After longer than the next wait is used instead, up to 6 hours. |
| Any other 4xx, or a redirect | Fails at once. Redirects are not followed. |
After the last attempt the delivery is marked failed, and whoever added the channel gets a notice in their saysOK inbox with your endpoint's status code and the start of its answer. The integration's Activity tab lists each delivery and whether it arrived.
A status page's subscriber is tried 5 times over about 8 minutes instead.
Because one delivery can arrive more than once (your endpoint did the work but answered too late, say), handle a repeated X-Saysok-Delivery-Id as already done.
What the endpoint must be
- An
https://URL. Plainhttp://is refused. - On a public address. saysOK does not send to private networks, loopback, link-local or cloud metadata addresses, checked again when each request is sent.
- No user name or password in the URL. Put credentials in a header of your own, or rely on the signature.