---
url: https://simpledatalogs.com/docs/guide/alerts.md
description: >-
  Threshold alerts by email, and on paid plans in Slack, Discord or a signed
  webhook, with the payload and how to check its signature.
---

# 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](https://api.slack.com/messaging/webhooks) 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`, `429` or `5xx` is retried with backoff (30 seconds, doubling, up to an hour) for about two hours.
* Any other `4xx` stops 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
}
```

* `state` is `firing` or `ok` (resolved). `firing_since` is `null` once resolved.
* `delivery_id` stays the same across retries; use it to drop duplicates.
* **Send test** posts `"type": "test"` with an empty `alerts` list.
* Answer with any `2xx` quickly; 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.

::: code-group

```js [Node]
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 [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 [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](/reference/limits).
