Notifications¶
Signalith can send a message when a finding is raised. It can send to a Slack channel, a Microsoft Teams channel or chat, email, or a webhook such as a SOAR or ticketing system. Set this up on the Notifications page, under Configure. Only admins see the page.
There are two parts:
- A channel is a place messages go, such as Slack #soc or the SOC mailbox.
- A rule says which findings go to which channels. For example: "High and critical findings from any detection go to Slack #soc and Email SOC team."
Nothing is sent until a rule sends findings to a channel.
Add a channel¶
On Notifications, choose Add channel and pick a kind. When there are no channels
yet, the page offers the four kinds itself. Each kind asks for a Name. The name is shown
on the page, in rule summaries and in emails, so name the place it posts to, such as #soc.
Each kind of channel has Send test. It sends a test message through the settings in the dialog, before you save them, and says whether it worked. If a test fails, the dialog shows why, in plain words: the address could not be found, the certificate is not trusted, the receiver answered 404, and so on.
Slack¶
- In Slack, add the Incoming Webhooks app to your workspace. Pick the channel and copy
its Webhook URL (
https://hooks.slack.com/services/...). - In Signalith, choose Add channel, then Slack. Paste the URL and name the channel
after the Slack channel, such as
#soc. - Choose Send test, then Add channel.
Messages use Slack Block Kit: the finding's title, its urgency, detection, ATT&CK techniques and entities, and a link to the Findings page.
Microsoft Teams¶
Teams messages go through a Workflows webhook. Microsoft retired the old Office 365 connectors ("Incoming Webhook"), and their URLs no longer work.
- In Teams, open the channel's ... menu, then Workflows. Choose Post to a channel when a webhook request is received. The template may also be called "Send webhook alerts to a channel".
- Pick the team and channel, then finish the workflow. Copy the URL it shows. The URL
usually starts with
https://prod-...logic.azure.com/workflows/...orhttps://... .environment.api.powerplatform.com/.... - In Signalith, choose Add channel, then Teams. Paste the URL, then Send test and Add channel.
Messages are Adaptive Cards (version 1.4) inside the {"type": "message", "attachments":
[...]} envelope that the Workflows template expects. A workflow you build yourself in
Power Automate works too, as long as it posts the card it receives.
Email¶
Signalith sends mail through your own mail server over SMTP.
| Setting | What to enter |
|---|---|
| Mail server, Port | Your mail server's name, such as smtp.office365.com, and its port |
| Connection | STARTTLS on port 587 (the default), TLS on port 465, or None. None is only allowed for a mail relay on this server (localhost), and sends no password. |
| User name, Password | The account to sign in with. Leave both empty for a server that does not need a sign-in. Passwords are only sent over an encrypted connection. Signalith uses PLAIN sign-in, or LOGIN when the server only offers that. |
| From | The address messages come from. Your mail server must allow the account to send as this address. |
| To | One or more addresses, separated by commas (at most 50) |
Signalith refuses to send when the server does not offer STARTTLS. It does not fall back to
sending in plain text. Each email has a plain-text body with the finding's details and a link.
The subject line is the urgency and title, such as [Signalith] High: Brute force against admin.
Webhook¶
A webhook channel POSTs a JSON body to any URL, such as a SOAR, a ticketing system or your own script.
- URL: where to send. A URL often holds a token, so Signalith encrypts it like a password.
- Signing secret (optional): signs each body so the receiver can check that it came from Signalith. See Checking the signature.
- Headers (optional): headers the receiver asks for, such as
Authorization: Bearer ...or an API key. Header values are encrypted. Signalith setsContent-Type,User-AgentandX-Signalith-Signatureitself, and these cannot be changed.
The receiver should answer with any 2xx status. The rest of the answer is not used. Signalith reads at most 64 KB of it, to quote in an error.
Advanced: private networks, this server, and private certificates¶
- Private addresses (10.x, 192.168.x, internal names) are allowed, since SOAR and firewalls live there.
- This server's own addresses (
localhost,127.0.0.1,::1) and cloud metadata addresses (such as169.254.169.254) are refused. This stops a channel from being used to reach services that only this machine can reach. To send to a receiver on the same machine, open Advanced and tick Allow this server's own addresses. Signalith's own OpenSearch address is always refused. - Signalith checks addresses after it looks up the name, so a name that resolves to a refused address is refused too.
- Certificate authority: certificates are always verified. If the receiver's certificate
comes from a private certificate authority, paste that authority's certificate (PEM, one
or more
-----BEGIN CERTIFICATE-----blocks) under Advanced. The system's trusted authorities still count. - Signalith does not follow a redirect to another server, and waits at most 10 seconds for an answer (30 seconds for email).
Add a rule¶
Choose Add rule. A rule has:
| Setting | Meaning |
|---|---|
| Name | Shown on the page and in deliveries |
| Send to | One or more channels |
| How urgent | The least urgency to send: Critical, High (the default), Medium, Low, or Any. Urgency is the finding's own (its severity, or the urgency set on it). |
| From | Any detection, or Only some: pick detections from the list |
| Least risk score (More options) | Only applies to findings that carry a risk score: the risk incident detections put one in result.risk_score. Other findings are sent whatever this says. Empty or 0 means any score. |
| Include sample data (More options) | Findings from the one-click sample data are skipped unless this is on. Turn it on to try a channel with sample data. |
| On | A rule that is off sends nothing |
The dialog shows a plain-English summary of the rule as you change it.
What gets sent:
- Only new findings are sent. A rule sends findings raised after it was added or last turned on, so a new rule does not send old findings. The job looks back over the last 30 minutes.
- Each finding goes to each channel once, even when several rules match it.
- The channels in one rule all get the finding.
- Flood guard: if one pass would send more than 10 messages to a channel, Signalith sends the 9 most urgent and then one summary, such as "And 23 more new findings", with a link to the Findings page.
- Messages link to
server.public_url+/#findings. Whenserver.public_urlis not set, messages have no link, and the page says so. Set it on the Settings page.
When you delete a channel, it is taken out of its rules. A rule left with no channel is turned off.
Send a test¶
Each channel card has Send test, and so does the channel dialog. A test message is
marked as a test, as in the webhook example below ("type": "test"). Tests are listed under
Recent deliveries like other messages. The card then shows "Test delivered 2m ago", or
the error.
Tests are sent from the server that runs the background jobs. On any other server the button is not shown (see below).
Recent deliveries and failures¶
Recent deliveries lists the latest messages: the finding, the channel, the status and when it was sent. Deliveries are kept for 30 days.
- Sent: the receiver accepted it. "on try 3" means earlier tries failed.
- Retrying: the send failed in a way that may pass, such as a timeout, a 5xx or 429 answer, or a temporary 4xx mail error. Signalith tries again after 1, 5, 15 and 60 minutes, so there are 5 tries in all.
- Failed: Signalith gave up. Some failures are not retried, because sending again would fail the same way: a refused address, a redirect to another server, a 4xx answer other than 408 and 429, or a permanent 5xx mail error.
Each channel card shows its last delivery. When a channel's last message failed, the Health page warns: "Notifications to Slack #soc are failing."
Errors never show a channel's secrets. The URL's token, header values, the signing secret
and the password are replaced with ***.
Which server sends¶
When several servers share one OpenSearch, only the server that holds the background
jobs sends notifications. This is the same server that runs detections (see
servers.md). It runs the notifications job every 15 seconds.
- Channels and rules live in that server's notifications file. On other servers the page shows them but cannot change them, and it names the server to use. If the jobs move to a server whose notifications file differs, the Servers page says so, as it does for the other settings files. Copy the file to every server that may run the jobs.
- Before it sends, the job claims each message by creating a record in OpenSearch (the
<prefix>_notificationsindex). The record's ID is made from the finding and the channel, so it is the same on every server. Only one server can create it, so only one server sends, even right after the jobs move. - If a server stops while sending, its message may or may not have arrived. Signalith does not send it again, since it would rather miss one than send it twice. After 5 minutes the delivery is marked failed: "The server stopped while sending this message; it may not have arrived."
- The job checks that this server still holds the jobs before each send.
- With
detections.scheduler: falseon every server, nothing is sent.
Where the settings live¶
Channels and rules are kept in notifications.yaml in the lookups folder (setting
notify.file, environment SIGNALITH_NOTIFY_FILE; a relative path is relative to the
config file). The server writes the file with mode 0600. Every secret in it is encrypted
with the master key (enc:v1:...): webhook URLs, header values, signing secrets and email
passwords. signalith secrets status, encrypt, rotate-master and the Security page
cover it, like the other settings files. Edit it on the page rather than by hand. A plain value
typed into the file still works, and it is encrypted the next time the page saves the file
or signalith secrets encrypt runs.
If the file cannot be read, nothing is sent, and the page shows the error. If a secret cannot be decrypted, for example after the master key is lost, that channel sends nothing. Its card says which secret to enter again.
The webhook body¶
Every webhook POST has Content-Type: application/json, User-Agent: Signalith and, when
the channel has a signing secret, X-Signalith-Signature. The body is version 1.
A finding:
{
"version": 1,
"type": "finding",
"sent": "2026-10-13T09:15:02Z",
"finding": {
"id": "f7c1e0a2b9d84d1c",
"title": "Brute force against admin from 203.0.113.9",
"urgency": "high",
"severity": "medium",
"detection": "brute_force",
"mitre": ["T1110"],
"entities": {"user": "admin", "host": "web-01", "src": "203.0.113.9"},
"risk_score": null,
"created": "2026-10-13T09:14:51Z",
"link": "https://siem.example.com/#findings",
"sample": false
}
}
| Field | Meaning |
|---|---|
version |
The body's version. It stays 1 while fields are only added; a change that breaks receivers would be 2. |
type |
finding, summary (flood guard) or test |
sent |
When this message was sent (UTC). A receiver can refuse old ones. |
finding.id |
The finding's ID in Signalith |
finding.urgency |
critical, high, medium, low or informational: the urgency the rule matched on. severity is the detection's own. |
finding.detection |
The detection's ID |
finding.mitre |
ATT&CK technique IDs; may be empty ([]) |
finding.entities |
Any of user, host, src and dest; may be empty ({}) |
finding.risk_score |
A number for findings of risk detections, else null |
finding.link |
server.public_url + /#findings, or empty when that is not set |
finding.sample |
true for a finding from sample data |
A flood guard summary:
{
"version": 1,
"type": "summary",
"sent": "2026-10-13T09:15:02Z",
"summary": {"count": 23, "text": "And 23 more new findings", "link": "https://siem.example.com/#findings"}
}
A test has "type": "test" and a finding with the ID test, the title "Test message from
Signalith", and made-up entities.
Receivers should ignore fields they do not know, so new fields can be added to version 1.
Checking the signature¶
With a signing secret, each POST has:
Compute the HMAC over the raw bytes of the body as received, before parsing the JSON.
Compare the values in constant time. Also check sent, so an old message cannot be
replayed; allowing five minutes is plenty.
Python:
import hashlib, hmac, json, time
from datetime import datetime
def verify(secret: str, body: bytes, header: str) -> dict:
want = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, header or ""):
raise ValueError("bad signature")
msg = json.loads(body)
sent = datetime.fromisoformat(msg["sent"].replace("Z", "+00:00")).timestamp()
if abs(time.time() - sent) > 300:
raise ValueError("too old")
return msg
Go:
func verify(secret string, body []byte, header string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(header))
}
Node.js:
const crypto = require('crypto');
function verify(secret, rawBody, header) {
const want = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return header && want.length === header.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(header));
}
API¶
Every route is for admins.
| Route | What it does |
|---|---|
GET /api/notify |
Channels, rules and the page's details: each channel without its secrets (url_set, password_set, signing_secret_set, header names only), its last delivery and the rules that use it; each rule with its summary; revision, file, manage (whether this server can change them) and file_error |
PUT /api/notify |
Replaces channels and rules with the body (revision, channels, rules). A secret left empty keeps its saved value; clear_password, clear_signing_secret and a header with clear remove one. A secret must be typed again when a channel's URL or mail server moves to another server. A body with problems is refused with 400 and problems (where, field, message). A stale revision, or another server running the jobs, gives 409. |
POST /api/notify/test |
Sends a test message: {"id": "<channel id>"} for a saved channel, or {"channel": {...}} for a channel being edited. Its empty secrets are the saved ones. Answers {ok, duration_ms, error}. |
GET /api/notify/deliveries?limit=50 |
The latest deliveries, newest first (at most 500) |