Seqlense docs

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_typeWhat is sentconfig
WEBHOOKJSON POST to your URL, optionally signedurl (required), secret, headers
DISCORDan embed coloured by severityurl of a Discord webhook
SLACKa Block Kit message coloured by severityurl of a Slack incoming webhook
IN_APPnothing is sent{}
EMAILnothing 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.

FilterCompared withExample
filter_severitythe alert's severity["HIGH", "CRITICAL"]
filter_sourcesthe alert's source["market_abuse", "orderbook_abuse"]
filter_entity_typesthe 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 write HIGH.
  • No severity ordering. There is no "MEDIUM and 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_type always passes filter_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

CallEffect
GET /v1/alerts/channelsall channels, newest first, without config
GET /v1/alerts/channels/detail?id=...one channel, with its config
PUT /v1/alerts/channelsupdate; 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 }'
  • config replaces the stored config, it is not merged. To change only the headers, send the url and secret again. It is not re-checked either: a config without url is accepted and the channel then silently sends nothing.
  • A filter is cleared with null or []. Omitting it keeps the current value.
  • channel_type cannot be changed; create a new channel instead.
  • is_active: false pauses 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 INFO or the system-test source. Clear its filters for the test, or test with a regular POST /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"
  }
}
  • event is always alert.created: acknowledging or resolving an alert sends nothing.
  • entity_type and entity_id are "" when the alert has no subject; metadata is null when none was given.
  • created_at is 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:

  1. Read the raw body before any JSON parsing. Re-serializing the parsed JSON will not reproduce the same bytes.
  2. Compute HMAC-SHA256 of those bytes with your secret, hex encoded.
  3. Compare it with the header value after sha256=, in constant time.
  4. 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:

PartContent
title[SEVERITY] title
descriptionthe alert message
fieldsSource, Severity, and Entity when the alert has one
footerAlert <id> • Seqlense Monitoring
colourred 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.

On this page