Notification channels
Forward new alerts to a signed webhook, Discord or Slack, filter what each channel receives, and verify webhook signatures.
A notification channel is a destination for new alerts. Whenever an alert is created, Seqlense sends it to every active channel of the workspace whose filters it matches. Channels belong to the workspace of the API key, so production and development have their own.
channel_type | What is sent | config |
|---|---|---|
WEBHOOK | JSON POST to your URL, optionally signed | url (required), secret, headers |
DISCORD | an embed coloured by severity | url of a Discord webhook |
SLACK | a Block Kit message coloured by severity | url of a Slack incoming webhook |
IN_APP | nothing is sent | {} |
EMAIL | nothing is sent | {} |
IN_APP and EMAIL channels are accepted and stored, but the dispatcher sends
nothing for them and records no delivery. Alerts appear in the web app whatever
channels you have. Only WEBHOOK, DISCORD and SLACK produce outbound
requests.
Creating a channel
POST /v1/alerts/channels with a name (1-100 characters), a channel_type,
and for the three URL-based types a config.url:
curl -X POST https://monitoring.seqlense.com/api/v1/alerts/channels \
-H "Authorization: Bearer sq_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Risk engine webhook",
"channel_type": "WEBHOOK",
"config": {
"url": "https://hooks.example.com/seqlense",
"secret": "whsec_5f2b8c0e1d",
"headers": { "X-Team": "compliance" }
},
"filter_severity": ["HIGH", "CRITICAL"]
}'{ "status": "ok", "id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b" }A new channel is active immediately. Without a url, a WEBHOOK, DISCORD or
SLACK channel is refused with WEBHOOK channel requires a url in config
(with the matching type name).
Filters
Each channel has three optional filters. A filter is a JSON array of strings; an alert must match every filter that is set.
| Filter | Compared with | Example |
|---|---|---|
filter_severity | the alert's severity | ["HIGH", "CRITICAL"] |
filter_sources | the alert's source | ["market_abuse", "orderbook_abuse"] |
filter_entity_types | the alert's entity_type | ["WALLET"] |
The matching rules are simple, and stricter than they look:
- Exact, case-sensitive comparison. Severities are stored upper-case, so
["high"]never matches anything. Always writeHIGH. - No severity ordering. There is no "
MEDIUMand above": list every level you want, for example["MEDIUM", "HIGH", "CRITICAL"]. null, an omitted filter, or[]means match everything. So does any value that is not an array of strings (a plain string"HIGH"is stored but ignored).- An alert without an
entity_typealways passesfilter_entity_types.
A channel that lists specific sources stops receiving anything from a new producer until that source is added to it. This is the usual reason a new alert appears in the app but never reaches Slack.
Managing channels
| Call | Effect |
|---|---|
GET /v1/alerts/channels | all channels, newest first, without config |
GET /v1/alerts/channels/detail?id=... | one channel, with its config |
PUT /v1/alerts/channels | update; only the fields you send change |
DELETE /v1/alerts/channels?id=... | delete the channel and its delivery records |
On PUT, send the channel id plus any of name, config, is_active,
filter_severity, filter_sources, filter_entity_types:
curl -X PUT https://monitoring.seqlense.com/api/v1/alerts/channels \
-H "Authorization: Bearer sq_..." \
-H "Content-Type: application/json" \
-d '{ "id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b", "is_active": false }'configreplaces the stored config, it is not merged. To change only the headers, send theurlandsecretagain. It is not re-checked either: a config withouturlis accepted and the channel then silently sends nothing.- A filter is cleared with
nullor[]. Omitting it keeps the current value. channel_typecannot be changed; create a new channel instead.is_active: falsepauses a channel and keeps its delivery history, which deleting does not.
GET /v1/alerts/channels/detail returns the config exactly as stored,
including the webhook secret in clear. Treat that response as a secret.
Testing a channel
POST /v1/alerts/channels/test with { "id": "<channel id>" } creates a real
alert and dispatches it:
{ "status": "ok", "test_alert_id": "8b9c0d1e-2f3a-4b5c-9d6e-7f8a9b0c1d2e" }The test alert has source system-test, severity INFO, title
Test alert, no entity, and metadata {}. It is dispatched like any other
alert, which has two consequences:
- It goes to every active channel whose filters match, not only the one you named.
- The channel you named receives nothing if its filters exclude
INFOor thesystem-testsource. Clear its filters for the test, or test with a regularPOST /v1/alerts/that matches them.
The test alert stays in your alert list as OPEN. Its delivery results are in
GET /v1/alerts/detail?id=<test_alert_id>; resolve or delete it afterwards.
Delivery
For each matching WEBHOOK, DISCORD or SLACK channel, Seqlense sends one
POST with Content-Type: application/json:
- all channels are sent to in parallel, and alert creation waits for them;
- each request has a 10 second timeout;
- there is no retry: one attempt per alert and channel;
- any 2xx response counts as delivered, anything else as failed.
Every attempt is recorded and shown in the deliveries of
GET /v1/alerts/detail:
{
"id": "7e8f9a0b-1c2d-4e3f-8a5b-6c7d8e9f0a1b",
"status": "FAILED",
"channel_name": "Risk engine webhook",
"channel_type": "WEBHOOK",
"response_code": 500,
"error_message": "HTTP 500 Internal Server Error",
"delivered_at": ""
}status is PENDING while the request is in flight, then DELIVERED or
FAILED. On a network error, response_code is null and error_message
starts with Connection error:.
Webhook payload
A WEBHOOK channel receives this body:
{
"event": "alert.created",
"alert": {
"id": "5b1f7c1e-2a4d-4c8e-9f3a-8d2e6b0c1a47",
"source": "market_abuse",
"severity": "HIGH",
"title": "Market abuse detected on 0xd90e...f31b",
"message": "Scan flagged wash_trading (score 74, threshold 60).",
"entity_type": "WALLET",
"entity_id": "9a0c3e2b-7d41-4f5a-b6e8-1c2d3e4f5a6b",
"metadata": { "scan_id": "2966a324-2b67-48ee-a0e1-3fe6b123e1b2" },
"created_at": "2026-09-24T14:02:11.482913+00:00"
}
}eventis alwaysalert.created: acknowledging or resolving an alert sends nothing.entity_typeandentity_idare""when the alert has no subject;metadataisnullwhen none was given.created_atis the RFC 3339 time of the send, not a stored field.- On the wire the JSON is compact (no whitespace), and key order is not guaranteed. Parse it, do not compare it as text.
Each key of config.headers with a string value is added as a request header,
which is a simple way to pass a static token your receiver expects. Non-string
values are ignored, and a header name that is not valid makes every delivery
fail. Do not set Content-Type or X-Signature-256 there.
Verifying webhook signatures
When the channel's config.secret is set (and not empty), every request
carries:
X-Signature-256: sha256=<signature>where <signature> is the lower-case hex HMAC-SHA256 of the raw request
body bytes, keyed with the secret's UTF-8 bytes. No timestamp or other
header is part of the signed data.
To verify:
- Read the raw body before any JSON parsing. Re-serializing the parsed JSON will not reproduce the same bytes.
- Compute HMAC-SHA256 of those bytes with your secret, hex encoded.
- Compare it with the header value after
sha256=, in constant time. - Reject the request if the header is missing or does not match.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SEQLENSE_WEBHOOK_SECRET;
const app = express();
// express.raw keeps the exact bytes Seqlense signed.
app.post("/seqlense", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Signature-256") ?? "";
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const { event, alert } = JSON.parse(req.body.toString("utf8"));
console.log(event, alert.id, alert.severity);
res.status(204).end();
});
app.listen(3000);The signature proves the body came from someone holding the secret, but since
it covers no timestamp it does not stop a captured request from being replayed.
Treat alert.id as an idempotency key and ignore an id you have already
processed. Answer quickly (within 10 seconds) and with a 2xx: there is no retry.
Discord and Slack
Both use the channel's config.url as-is and are not signed.
Discord receives one embed:
| Part | Content |
|---|---|
| title | [SEVERITY] title |
| description | the alert message |
| fields | Source, Severity, and Entity when the alert has one |
| footer | Alert <id> • Seqlense Monitoring |
| colour | red CRITICAL, orange HIGH, yellow MEDIUM, blurple INFO |
Slack receives one attachment with a colour bar (red, orange, yellow, blue
for CRITICAL to INFO) and Block Kit blocks: a header
<emoji> [SEVERITY] title (:rotating_light:, :fire:, :warning:,
:information_source:), the message, a fields section with source, severity
and entity, and a context line Alert <id> • Seqlense Monitoring.
For every field and error message, see the API reference.