Skip to content

Notifications and integrations

Events, rules, destinations, signed webhooks

Edit this page
On this page

One delivery pipeline, five destinations. Queuing, retry, signing and deduplication are the pipeline's job and happen once; a destination is a kind and a formatter, not another subsystem.

Configure under Settings → Integrations.

Events

EventFires when
host.addedA host is discovered that was not there before
host.removedA known host stops being seen
service.changedA service starts, stops or changes version
tls.expiringA certificate crosses an expiry threshold
vuln.criticalA critical finding is matched
vuln.highA high finding is matched
snapshot.diff_significantA snapshot diff crosses your threshold
daemon.offlineA daemon stops reporting

Event names are versioned by their payload contract, not by a suffix: adding a field is safe, and changing what a field means gets a new name — so a rule written against the old meaning keeps matching what it meant.

Rules

A rule is an event type, an optional condition and a destination.

Conditions are a small typed set rather than an expression language: equality, greater-than-or-equal, membership and a match-everything. An alert rule engine is a product in itself, and every one of them ends up with its own syntax to learn and its own way to be wrong. If these four are not enough, that is worth reporting with the case that broke them.

Destinations

KindYou supplySigned
WebhookURLYes
SlackIncoming webhook URLNo
Microsoft TeamsIncoming webhook URLNo
PagerDutyIntegration routing keyNo
JiraBase URL, account email, API token, project keyNo

Slack and Teams take an incoming webhook URL, which is itself the credential — there is nothing to store separately and nothing to sign, because the receiver cannot verify a signature anyway.

PagerDuty and Jira post to an API with a token. That token goes in the same encrypted credentials table as every other secret. PagerDuty's Events API endpoint is fixed for everybody, so Loomscope supplies it rather than inviting a typo; Jira's is per-deployment, so it stays a field.

Webhook signing

A plain webhook receives the event as JSON — the data, not a rendering of it — with an HMAC-SHA256 signature over exactly that body:

x-loomscope-signature: t=1755500000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b...

The signed string is {timestamp}.{body}. Verify it like this:

python
import hmac, hashlib, time

def verify(secret: str, body: str, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:      # refuse a replayed capture
        return False
    expected = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

The timestamp is part of the signature and is enforced. A signature older than the 300-second tolerance is refused even when the MAC verifies — which is what stops a captured request being replayed against your endpoint indefinitely.

The v1 prefix exists so the algorithm can change later without a receiver having to guess which one was used.

Deduplication

Every event carries an idempotency key chosen by whatever emitted it, because only the emitter knows what "the same announcement" means:

  • tls.expiring keys on the certificate and the threshold, so a certificate found every hour announces once at 30 days rather than seven hundred times.
  • daemon.offline keys on the daemon and the outage, so a flapping daemon cannot flood a channel.

Delivery and retry

The notify.deliver job runs every minute — often, because this is the queue carrying alerts, and a minute of latency on "a critical CVE was found" is about the most anyone should accept. The tick costs nothing when nothing is due.

A failed delivery is retried with backoff. HTTP 408, 429 and any 5xx are treated as transient; a 4xx is not retried, because a rejected body will be rejected again.

What a rule would have done

Every rule on Settings → Integrations carries a preview: how many times it would have fired over the last 30 days, and what that number was counted from.

It exists to stop the rule that looks reasonable on a Friday and pages somebody four hundred times over the weekend. The count is worth reading before saving a rule and worth re-reading when a channel has become noisy — a rule list without it makes the noisy rule and the quiet one look identical.

The condition is applied with the same function the delivery pipeline uses, so the preview and the real thing cannot disagree about what a rule means.

Two event types cannot be previewed, and say so instead of returning zero:

EventWhy
service.changedA service records its current version, not the versions it has held
daemon.offlineA daemon records only when it was last seen; past silences leave no trace

Zero and "cannot know" look the same in a number and mean opposite things, which is the whole reason they are told apart here.

Testing a destination

Every destination has a Send test button. It delivers a real event through the real pipeline — same formatter, same signature, same queue — rather than a special-cased ping that proves less than it appears to.

What is not here

  • No email. There is no mail transport in the product; magic-link sign-in is affected by the same gap. A webhook to something that does send mail is the current answer.
  • No per-user notification preferences. Rules belong to the organisation, not to a person.
  • No scheduled digests. Events are delivered as they happen.