Skip to content

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.

Email

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.

HeaderValue
Content-Typeapplication/json
User-AgentSentientOne-Alerts/1
X-SentientOne-Eventalert.firing, alert.resolved or alert.test
X-SentientOne-TimestampUnix time in seconds when it was sent
X-SentientOne-Signaturesha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the alert's whsec_ secret
Body (formatted here; sent as compact JSON)
{  "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"}
  • type and state say what happened: alert.firing / firing, alert.resolved / resolved, or alert.test / test.
  • value is the measured number, unrounded; events is how many replies, ratings or scores it was measured over (null where it doesn't apply).
  • unit is percent, count, ms, score or credits. metric is one of error_rate, error_count, p95_latency_ms, request_count, thumbs_down_rate, feedback_score, credits_spent, credits_left and workflow_failures.

Verify the signature

  1. 1

    Read the two headers

    Take X-SentientOne-Timestamp and X-SentientOne-Signature from the request.

  2. 2

    Check the timestamp

    Refuse deliveries older than a window you choose (5 minutes in the examples) to block replays.

  3. 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 with sha256=.

  4. 4

    Compare in constant time

    Use crypto.timingSafeEqual or hmac.compare_digest, never ==.

Node.js (Express)
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)})
Python (Flask)
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 "", 200

This 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.

support@sentientone.ai