Webhooks
Suta sends a JSON POST to each webhook when an alert opens or resolves, and
when you select Send test. To add a webhook, see
Alerts and webhooks.
Payload
Section titled “Payload”| Field | Description |
|---|---|
type |
alert.opened, alert.resolved or test. |
text |
A one-line summary. Slack displays it as the message. |
alert.kind |
offline, memory, storage, temperature or agent_update_failed. |
alert.status |
open or resolved. |
alert.details |
The reading and the limit that triggered the alert. |
device |
The device’s ID and name. |
workspace |
The workspace’s ID and name. |
url |
A link to the device in Suta. |
{ "type": "alert.opened", "text": "[Suta] Alert: Lobby camera storage is 96% full (limit 90%)", "alert": { "id": "8c1f…", "kind": "storage", "status": "open", "summary": "Lobby camera storage is 96% full (limit 90%)", "details": { "percent": 96.0, "threshold": 90.0 }, "opened_at": "2026-09-23T10:00:00Z", "resolved_at": null }, "device": { "id": "…", "name": "Lobby camera" }, "workspace": { "id": "…", "name": "Acme Robotics" }, "url": "https://app.suta.dev/devices/…"}Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Suta-Alerts/1 |
Suta-Signature |
t=<unix seconds>,v1=<hex HMAC> |
POST /hooks/suta HTTP/1.1Content-Type: application/jsonUser-Agent: Suta-Alerts/1Suta-Signature: t=1790157600,v1=6f1d…Verify signatures
Section titled “Verify signatures”v1 is an HMAC-SHA256 of <t>.<raw body>, keyed with the webhook’s signing
secret, as lowercase hex.
To verify a request:
- Read the raw request body before parsing it as JSON.
- Split
Suta-Signatureintotandv1. - Compute the HMAC of
t, a., and the raw body. - Compare it with
v1in constant time. - Reject the request if
tis more than five minutes old.
import crypto from "node:crypto";
export function verify(secret, header, rawBody) { const { t, v1 } = Object.fromEntries( header.split(",").map((part) => part.split("=")), ); const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; return ( fresh && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) );}import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts["t"], parts["v1"] expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256 ).hexdigest() fresh = abs(time.time() - int(t)) < 300 return fresh and hmac.compare_digest(v1, expected)func Verify(secret, header string, rawBody []byte) bool { var t, v1 string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(part, "=") if k == "t" { t = v } if k == "v1" { v1 = v } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(v1), []byte(expected))}Everything runs in your browser. Nothing you paste here is sent anywhere.
Paste a secret, header and body to check them.
Delivery
Section titled “Delivery”- Suta treats any
2xxresponse as delivered. - A different status, a timeout after 10 seconds or a connection error is retried after 1, 2, 4, 8 and 16 minutes, then marked as failed.
- Delivery is at least once, so the same alert can arrive more than once. Use
alert.idandtypeto ignore duplicates. - Suta doesn’t follow redirects, and only sends to public HTTPS addresses.