SectionsWebhook

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.

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

Events

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

Body

json
{
  "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
  }
}
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

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

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

  1. Reject the request if X-SmartAlert-Timestamp is more than 300 seconds from your clock.
  2. Compute the signature and compare it with the header in constant time.
  3. Drop duplicates by X-SmartAlert-Delivery.

Node

You need the raw body: in Express use express.raw, not express.json.

js
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

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

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

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

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

The 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