Alerts
Alert emails, Slack messages and webhooks
What each notification contains, the webhook's JSON body and headers, and how to verify its X-SentientOne-Signature in Node or Python.
Guide3 min readUpdated 31 October 2026
An alert notifies when it fires, when it resolves, and when you send a test. Each notification carries the same message, such as "Error rate for Support bot is 13% over the last 1 hour (40 events), above 5%.", and a link to the alert.
Sent to your account's email address. The subject is "Alert firing: <name>", "Alert resolved: <name>" or "Test alert: <name>", and the email shows the message, the scope, the value, the threshold and an Open the alert button. Switch Email off on the alert to stop it.
Slack
Paste a Slack incoming webhook URL; it must start with https://hooks.slack.com/. Each message starts with :rotating_light: when firing, :white_check_mark: when resolved or :bell: for a test, then the alert's title, the message and a link to open the alert. The URL is stored encrypted and shown masked after you save.
Webhook
A POST with a JSON body to a public https address, with 10 seconds to answer. Redirects aren't followed and only a 2xx answer counts as sent. The address can't contain a user name or password, and it's checked to be on the public internet each time it's called.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SentientOne-Alerts/1 |
X-SentientOne-Event | alert.firing, alert.resolved or alert.test |
X-SentientOne-Timestamp | Unix time in seconds when it was sent |
X-SentientOne-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the alert's whsec_ secret |
{ "type": "alert.firing", "alert": { "id": "…", "name": "Error rate above 5% on Support bot", "metric": "error_rate", "feedback_key": null, "comparison": "above", "threshold": 5, "unit": "percent", "window_minutes": 60, "agent_id": "…", "workflow_id": null, "channels": ["widget", "link", "api"] }, "state": "firing", "value": 12.5, "events": 40, "message": "Error rate for Support bot is 13% over the last 1 hour (40 events), above 5%.", "url": "https://app.sentientone.ai/alerts/…", "created_at": "2026-10-31T09:15:00.000Z"}typeandstatesay what happened:alert.firing/firing,alert.resolved/resolved, oralert.test/test.valueis the measured number, unrounded;eventsis how many replies, ratings or scores it was measured over (null where it doesn't apply).unitispercent,count,ms,scoreorcredits.metricis one oferror_rate,error_count,p95_latency_ms,request_count,thumbs_down_rate,feedback_score,credits_spent,credits_leftandworkflow_failures.
Verify the signature
- 1
Read the two headers
Take
X-SentientOne-TimestampandX-SentientOne-Signaturefrom the request. - 2
Check the timestamp
Refuse deliveries older than a window you choose (5 minutes in the examples) to block replays.
- 3
Compute the HMAC
HMAC-SHA256 over the timestamp, a full stop and the raw body, keyed with the whole secret (
whsec_included), as lowercase hex, prefixed withsha256=. - 4
Compare in constant time
Use
crypto.timingSafeEqualorhmac.compare_digest, never==.
import crypto from "node:crypto"import express from "express"const app = express()const SECRET = process.env.SENTIENTONE_ALERT_SECRET // whsec_…// Keep the raw body: the signature is over the exact bytes sentapp.post("/hooks/sentientone", express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.get("X-SentientOne-Timestamp") || "" const signature = req.get("X-SentientOne-Signature") || "" // Refuse old deliveries (5 minutes here; pick your own window) if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400) const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(timestamp + "." + req.body.toString("utf8")).digest("hex") const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) if (!ok) return res.sendStatus(401) const event = JSON.parse(req.body.toString("utf8")) if (event.type === "alert.firing") { // page someone, open an incident… } res.sendStatus(200)})import hashlib, hmac, os, timefrom flask import Flask, abort, requestapp = Flask(__name__)SECRET = os.environ["SENTIENTONE_ALERT_SECRET"].encode() # whsec_…@app.post("/hooks/sentientone")def sentientone_alert(): timestamp = request.headers.get("X-SentientOne-Timestamp", "") signature = request.headers.get("X-SentientOne-Signature", "") if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: abort(400) raw = request.get_data() # the exact bytes sent digest = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, "sha256=" + digest): abort(401) event = request.get_json() if event["type"] == "alert.firing": pass # page someone, open an incident… return "", 200This is the same scheme as the workflow Webhook block, so one verifier can check both. Each alert has its own secret. See Verify webhook signatures.
The signing secret
- It starts with
whsec_and belongs to one alert. It's shown once, right after a save that creates it: a new alert with a webhook, adding a webhook to an alert, or New signing secret. - New signing secret replaces it at once, with no undo, so update your endpoint straight away.
- Changing the webhook URL keeps the secret. It's stored encrypted and can't be shown again.
You're done when
Send test alert reaches your endpoint, it accepts the alert.test delivery, and a request with a changed body or an old timestamp is refused.
Still stuck?
Email us with what you tried and any request id. A person replies.