Appearance
Alerts and channels
An alert watches one count in an environment: an event name, a level, or both, above or below a number in a window (5 minutes to 24 hours). "Below 1 in an hour" catches silence.
You get one message when an alert fires and one when it's back to OK. Alerts that change in the same check are grouped into one message. Snoozing an alert silences it for 24 hours; its state keeps updating.
| Free | Starter | Pro | Scale | |
|---|---|---|---|---|
| Checked every | 5 minutes | Minute | Minute | Minute |
| Email to owners | ✓ | ✓ | ✓ | ✓ |
| Slack, Discord, webhooks | — | ✓ | ✓ | ✓ |
Channels
Owners add channels on an environment's Alerts page, under Channels. A channel belongs to the organization, so every environment can use it. Pick the channels each alert sends to in the New alert dialog or in the alert's menu under Channels. Email to the owners always goes out too.
- Slack: create an app with an incoming webhook and paste its
https://hooks.slack.com/services/…URL. - Discord: in the channel's settings, open Integrations → Webhooks, create one and paste its
https://discord.com/api/webhooks/…URL. - Webhook: any public HTTPS URL. We sign every request (below).
Send test posts a test message right away and shows whether it worked. Each channel shows Working or Failing after its last delivery.
Webhook URLs and signing secrets are stored encrypted. Only owners see a channel's host and can test, delete or read the signing secret. After a downgrade to Free, channels stay but pause; email keeps working.
Delivery
- A message is sent within a minute of the check, never holding up the email.
- A timeout,
408,429or5xxis retried with backoff (30 seconds, doubling, up to an hour) for about two hours. - Any other
4xxstops the retries. Fix or re-add the channel. - Redirects are not followed. Each request has 5 seconds to connect and 10 to answer.
- A webhook host must resolve only to public addresses, checked before every send. Private, loopback and link-local addresses are refused.
- One message lists at most 20 alerts, then "…and N more".
Webhook payload
A POST with content-type: application/json:
json
{
"type": "alerts",
"delivery_id": "01928f3e-5c1a-7b2e-9f00-3c4d5e6f7a8b",
"organization_id": "01928f3e-0000-7000-8000-000000000001",
"created_at": "2026-10-19T14:05:00.123456Z",
"alerts": [
{
"id": "01928f3e-0000-7000-8000-0000000000aa",
"name": "Errors",
"state": "firing",
"environment_id": "01928f3e-0000-7000-8000-0000000000e1",
"event": null,
"level": "error",
"comparison": "above",
"threshold": 50,
"window_minutes": 15,
"value": 73,
"firing_since": "2026-10-19T14:05:00Z",
"url": "https://simpledatalogs.com/environments/01928f3e-0000-7000-8000-0000000000e1/alerts"
}
],
"more_count": 0
}stateisfiringorok(resolved).firing_sinceisnullonce resolved.delivery_idstays the same across retries; use it to drop duplicates.- Send test posts
"type": "test"with an emptyalertslist. - Answer with any
2xxquickly; the body is ignored.
Checking the signature
Every webhook request carries three headers:
| Header | Value |
|---|---|
x-sdl-timestamp | Unix seconds when the request was signed |
x-sdl-signature | v1= and the hex HMAC-SHA256 of timestamp.body |
x-sdl-delivery | The payload's delivery_id |
The key is the channel's signing secret (whsec_…), shown when you add the channel and again from its menu under Signing secret. Compute the HMAC over the raw body, before any JSON parsing, compare in constant time, and refuse timestamps older than 5 minutes.
js
import crypto from 'node:crypto'
export function isValidSignature(rawBody, headers, secret) {
const timestamp = headers['x-sdl-timestamp']
const signature = headers['x-sdl-signature'] ?? ''
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}python
import hashlib, hmac, time
def is_valid_signature(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers["x-sdl-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(headers.get("x-sdl-signature", ""), expected)php
function isValidSignature(string $rawBody, array $headers, string $secret): bool
{
$timestamp = $headers['x-sdl-timestamp'];
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $headers['x-sdl-signature'] ?? '');
}Limits
| Every paid plan | |
|---|---|
| Channels per organization | 20 |
| Alerts per message | 20 |
| Connect / answer timeout | 5 s / 10 s |
| Retries | About 2 hours |
Alert limits per plan are on Plans and limits.