Documentation
Webhook
The webhook channel sends a JSON request to your HTTPS URL on every alert event. It is available on Pro and up. To connect the channel, see Notifications.
On this page
Request
POST to the channel URL, Content-Type: application/json, User-Agent: SmartAlert-Webhook/1. Timeout is 5 seconds, redirects are not followed.
| Header | Value |
|---|---|
X-SmartAlert-Timestamp | Send time, Unix seconds, for example 1790000000 |
X-SmartAlert-Signature | sha256=<hex>, the signature |
X-SmartAlert-Delivery | Delivery UUID. A retry of the same delivery carries the same id: use it to drop duplicates |
X-SmartAlert-Event | alert.firing, alert.resolved, alert.no_data or test |
X-SmartAlert-Timestamp1790000000X-SmartAlert-Signaturesha256=<hex>, the signatureX-SmartAlert-DeliveryX-SmartAlert-Eventalert.firing, alert.resolved, alert.no_data or testEvents
| Event | When |
|---|---|
alert.firing | The rule fired |
alert.resolved | The value is back to the norm. Same alert.id as alert.firing |
alert.no_data | The series went silent: no data |
test | The Send test button. The alert field is null |
alert.firingalert.resolvedalert.id as alert.firingalert.no_datatestalert field is nullBody
{
"version": 1,
"event": "alert.firing",
"deliveryId": "6f1c…",
"sentAt": "2026-10-17T12:00:00Z",
"alert": {
"id": "0b8e…",
"url": "https://…/alerts/0b8e…",
"severity": "critical",
"ruleId": "c2d1…",
"ruleName": "Liquidity outflow",
"project": { "id": "4a7f…", "name": "Vault" },
"contract": { "network": "ethereum", "address": "0xabc…", "name": "Pool" },
"labels": { "token": "USDC" },
"condition": "gt",
"value": 1250000,
"threshold": 1000000,
"band": null,
"conditions": null,
"startedAt": "2026-10-17T11:58:00Z",
"resolvedAt": null
}
}| Field | Meaning |
|---|---|
version | Always 1 |
event | Same as X-SmartAlert-Event |
deliveryId | Same as X-SmartAlert-Delivery |
sentAt | Send time, RFC 3339 UTC |
alert | null for test, otherwise the firing |
alert.id | The incident: firing and resolved of one incident carry the same id |
alert.url | The firing page in the app |
alert.severity | critical, warning or info |
alert.labels | The series (the rule's grouping values). {} if there is no grouping |
alert.condition | The operator of the rule condition |
alert.value, alert.threshold | The value and the threshold |
alert.band | {"norm": n, "edge": e} for a rule on the norm: the norm and the edge that was crossed. Then threshold is null |
alert.conditions | For a two-condition rule — [{"met": true, "text": "No top-up for 1 h"}, …]: one item per condition, met tells whether it held, text in the channel language. null for a single-condition rule |
alert.unit | "usd" for a rule with a $ threshold (Rules): value and threshold are then in $ |
alert.transfer | For a rule with a $ threshold — the transfer that gave the value: {"txHash", "blockNumber", "amount", "symbol", "price", "priceSource", "priceAt"}. null for other rules |
alert.call | For a contract state rule (Contracts) — the function's answer: {"signature", "args", "raw", "previous", "reverted"}. value is 1 or 0 for bool, null for address. null for other rules |
alert.startedAt | Start of the incident |
alert.resolvedAt | End of the incident for alert.resolved, otherwise null |
text | The rule's custom notification text (Notifications), null if there is none |
version1eventX-SmartAlert-EventdeliveryIdX-SmartAlert-DeliverysentAtalertnull for test, otherwise the firingalert.idfiring and resolved of one incident carry the same idalert.urlalert.severitycritical, warning or infoalert.labels{} if there is no groupingalert.conditionalert.value, alert.thresholdalert.band{"norm": n, "edge": e} for a rule on the norm: the norm and the edge that was crossed. Then threshold is nullalert.conditions[{"met": true, "text": "No top-up for 1 h"}, …]: one item per condition, met tells whether it held, text in the channel language. null for a single-condition rulealert.unit"usd" for a rule with a $ threshold (Rules): value and threshold are then in $alert.transfer{"txHash", "blockNumber", "amount", "symbol", "price", "priceSource", "priceAt"}. null for other rulesalert.call{"signature", "args", "raw", "previous", "reverted"}. value is 1 or 0 for bool, null for address. null for other rulesalert.startedAtalert.resolvedAtalert.resolved, otherwise nulltextnull if there is noneA missing value is always null; the field does not disappear. New fields may appear in version 1: ignore the ones you do not know.
Verify the signature
SmartAlert shows the secret (whsec_…) once: when you create the channel and when you change the secret. The HMAC key is the whole secret string, prefix included.
signature = hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
X-SmartAlert-Signature: sha256=<signature>rawBody is the body bytes as received, before JSON parsing. The timestamp is part of the signature, so a captured request cannot be replayed with a new one.
- Reject the request if
X-SmartAlert-Timestampis more than 300 seconds from your clock. - Compute the signature and compare it with the header in constant time.
- Drop duplicates by
X-SmartAlert-Delivery.
Node
You need the raw body: in Express use express.raw, not express.json.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SMARTALERT_SECRET; // whsec_…
function verify(secret, headers, rawBody) {
const ts = headers["x-smartalert-timestamp"];
const got = headers["x-smartalert-signature"];
if (!ts || !got || !Number.isFinite(Number(ts))) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex");
const a = Buffer.from(got);
const b = Buffer.from(`sha256=${mac}`);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/smartalert", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.headers, req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// Drop duplicates by event.deliveryId and process asynchronously.
res.sendStatus(200);
});
app.listen(3000);Python
import hashlib
import hmac
import time
def verify(secret: str, headers, raw_body: bytes) -> bool:
ts = headers.get("X-SmartAlert-Timestamp", "")
got = headers.get("X-SmartAlert-Signature", "")
try:
if abs(time.time() - int(ts)) > 300:
return False
except ValueError:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), got)In Flask, raw_body is request.get_data(); in FastAPI, await request.body(). Take the bytes before JSON parsing.
Change the secret
The Change secret button issues a new secret. It works at once and there is no overlap: the old secret fails the check from the first request after the change. Update the secret on the receiving side right before or right after the change.
Response and retries
| Receiver response | What SmartAlert does |
|---|---|
| 2xx | Delivered. The response body is read up to 200 characters, shown in the delivery log |
| 408, 429, 5xx, timeout, connection drop | Up to 5 attempts with pauses of 1, 4, 16, 64 and 256 seconds. Retry-After on 429 is honored, up to 5 minutes |
| 404, 410 | The webhook is gone: the delivery is failed, the channel becomes "disabled", and the owner gets an email. Real deliveries only; a test does not disable the channel |
| Anything else, including 3xx | failed, no retries |
Retry-After on 429 is honored, up to 5 minutesfailed, the channel becomes "disabled", and the owner gets an email. Real deliveries only; a test does not disable the channelfailed, no retriesAnswer fast, within 5 seconds, and process asynchronously. A retry carries the same X-SmartAlert-Delivery.
Test
Send test sends a test event with alert: null, signed with the same secret. A 2xx answer turns the channel on. The test request is a convenient way to write and debug a handler.
PagerDuty through webhook
PagerDuty has a channel of its own: an integration key, no function in between, see Notifications → PagerDuty. Use this recipe only if you need your own incident format.
PagerDuty Events API v2 accepts its own format, not this one, so a small function sits between the webhook and PagerDuty (a cloud function, Zapier, n8n). It verifies the signature, maps the fields and calls the PagerDuty API.
| PagerDuty | From the webhook |
|---|---|
dedup_key | alert.id |
event_action: trigger or resolve | event: alert.firing or alert.resolved |
payload.summary | alert.ruleName and alert.contract.name |
payload.severity | alert.severity |
links[].href | alert.url |
dedup_keyalert.idevent_action: trigger or resolveevent: alert.firing or alert.resolvedpayload.summaryalert.ruleName and alert.contract.namepayload.severityalert.severitylinks[].hrefalert.urlThe same alert.id on alert.firing and alert.resolved lets you close the same incident in PagerDuty. Do not translate alert.no_data and test into its format unless you need them: the function can answer 200 and do nothing.
Found a mistake or didn’t find an answer? Write to us