---
title: alerts
description: Check fixed rules on the records each read returns and call your own notifier with what fired, using the four built-in rules or rules you write.
sidebar:
  label: alerts
related:
  - /guides/deliverability-check
  - /docs/insights/operations
  - /docs/api/stats
---

`alerts({ rules, notify })` from `outbound-sdk/alerts` checks every record a read returns against a list of rules and calls your `notify` function with the alerts that fired. A rule is arithmetic on one record the read returned: a rate against a threshold, a status against a value. No model is involved. The plugin fetches nothing, schedules nothing and remembers nothing: rules run only on the reads you make.

## Sample

Runs on the [shared seed](/docs/plugins#every-plugin), where one campaign has 18 bounces in 200 sends and one sender is disconnected.

```ts title="run.ts"
import { createOutbound } from "outbound-sdk";
import {
  alerts,
  bounceRateAbove,
  noSends,
  replyRateBelow,
  senderNotActive,
} from "outbound-sdk/alerts";
import type { Alert, AlertRule } from "outbound-sdk/alerts";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// A rule of your own: any function from a record to an alert or null.
// This one fires for a campaign that carries no tag.
const untaggedCampaign: AlertRule = {
  check: (record) =>
    "channel" in record && "tags" in record && record.tags.length === 0
      ? {
          message: `${record.name} has no tag, so no client rule can match it`,
          rule: "untagged-campaign",
          subject: record.ref,
          threshold: 1,
          value: 0,
        }
      : null,
  name: "untagged-campaign",
};

const fired: Alert[] = [];

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    alerts({
      // Post to Slack, page someone, write a row. Here: keep them.
      notify: (found) => {
        fired.push(...found);
      },
      rules: [
        bounceRateAbove(0.05),
        replyRateBelow(0.02),
        senderNotActive(),
        noSends(),
        untaggedCampaign,
      ],
    }),
  ],
});

// Rules run on whatever these reads return. Nothing else is fetched.
for await (const campaign of outbound.campaigns.listAll()) {
  await outbound.campaign(campaign.ref).stats({
    from: "2026-11-12",
    to: "2026-11-13",
  });
}

await outbound.senders.list();

await outbound.campaign("cmp_ecom").stats({
  by: "day",
  from: "2026-11-11",
  to: "2026-11-13",
});

for (const alert of fired) {
  console.log(`[${alert.rule}] ${alert.subject}: ${alert.message}`);
}

// evaluate() checks records you already hold, and does not call notify.
const page = await outbound.senders.list();
const notified = fired.length;
const found = outbound.alerts.evaluate(page.items);

console.log(
  "\nevaluate:",
  found.map((alert) => alert.rule)
);

console.log("notify called by evaluate:", fired.length > notified);
```

```text title="Output"
[untagged-campaign] memory:agency:cmp_ecom: Ecommerce ops has no tag, so no client rule can match it
[bounce-rate-above] memory:agency:cmp_ecom: Bounce rate is 9.0%, above 5.0%
[reply-rate-below] memory:agency:cmp_ecom: Reply rate is 1.0%, below 2.0%
[sender-not-active] memory:agency:snd_ben: Sender is disconnected
[no-sends] memory:agency:cmp_ecom: Nothing was sent on 2026-11-11
[bounce-rate-above] memory:agency:cmp_ecom: Bounce rate is 9.0%, above 5.0%
[reply-rate-below] memory:agency:cmp_ecom: Reply rate is 1.0%, below 2.0%
[bounce-rate-above] memory:agency:cmp_ecom: Bounce rate is 9.0%, above 5.0%
[reply-rate-below] memory:agency:cmp_ecom: Reply rate is 1.0%, below 2.0%

evaluate: [ 'sender-not-active' ]
notify called by evaluate: false
```

The bounce and reply alerts appear three times: once for the two-day total, and once for each day of the by-day read, which is a separate read returning separate rows. By default nothing removes repeats. Pass `dedupe` to be told once per rule and subject.

## Options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `rules` | `AlertRule[]` | required | Checked against every record, in order. |
| `notify` | `(alerts: Alert[]) => void \| Promise<void>` | required | Called once per operation that fired at least one alert, with all of that operation's alerts. Not awaited. |
| `dedupe` | `AlertDedupe` | none | A store of the alerts already notified: `has(key)` and `add(key)`, sync or async. With it, an alert is notified once per rule and subject (the key is `rule\|subject`), however many reads show it, and `notify` gets only the alerts not seen before. `memoryAlertDedupe()` keeps the keys in this process's memory, for one run. Supply your own to remember across runs: the SDK stores nothing itself. A store that fails is ignored and cannot fail the read; nothing is notified for that read. |

## Built-in rules

| Rule | Fires for | Alert `rule` |
| --- | --- | --- |
| `bounceRateAbove(threshold, { minSent })` | A stats row whose bounce rate (bounced divided by sent) is above `threshold`, a fraction from 0 to 1. | `bounce-rate-above` |
| `replyRateBelow(threshold, { minSent })` | A stats row whose reply rate is below `threshold`. The rate is replies divided by leads contacted, or by messages sent when the vendor does not report contacted. | `reply-rate-below` |
| `senderNotActive()` | A sender whose status is anything except `active` or `unknown`. | `sender-not-active` |
| `noSends()` | A stats row for one day (`date` is set) whose `sent` is exactly `0`. | `no-sends` |

Both rate rules skip a row with fewer sends than `minSent` (default `50`), and a row whose rate is `null` because the vendor did not report a count. A missing count never fires a rule.

`noSends()` needs the vendor to return a row for the silent day. A vendor that leaves empty days out of its daily stats gives it nothing to check.

## Alerts and your own rules

An `Alert` is:

| Field | Type | Meaning |
| --- | --- | --- |
| `rule` | `string` | The rule's name. |
| `subject` | `string` | The ref of the campaign, sender or workspace it is about. For a stats row, its `scopeRef`. |
| `message` | `string` | A sentence, for example "Bounce rate is 9.0%, above 5.0%". |
| `value` | `number \| string \| null` | What was measured. |
| `threshold` | `number \| string \| null` | What it was compared with. |

An `AlertRule` is `{ name, check }`, where `check(record)` returns an `Alert` or `null`. It receives every kind of record the instance returns, so it starts by telling them apart, as `untaggedCampaign` does in the sample. A rule sees one record at a time. Anything that needs two records, or a change since last week, is not a rule: see [insights](/docs/insights).

## What it adds

| Member | Returns |
| --- | --- |
| `outbound.alerts.evaluate(records)` | The alerts those records fire. It does not call `notify`. |

Use `evaluate` to check records you already hold, or to test a rule.

## What it changes

It changes no capability, no record and nothing in `meta`. Records pass through untouched.

## With other plugins

- Inside `cache`, rules run once per real read. Before `cache`, they run on every read, cache hits included.
- Listed after `redact`, rules see unmasked records. An alert `message` you build from a lead's fields carries them unmasked to your notifier.
- A stats row served stale by `failover` fires the same alerts as a fresh one. The rule cannot see `meta`.

## When it fails

```ts title="failures.ts"
import { createOutbound } from "outbound-sdk";
import { alerts, bounceRateAbove, senderNotActive } from "outbound-sdk/alerts";
import type { Alert } from "outbound-sdk/alerts";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// 1. A notifier that fails cannot fail the read. The failure is dropped.
const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    alerts({
      notify: () => Promise.reject(new Error("Slack is down")),
      rules: [senderNotActive()],
    }),
  ],
});

const senders = await outbound.senders.list();

console.log("read succeeded:", senders.items.length, "senders");

// 2. A rate on a handful of sends is noise, so small rows are skipped.
//    One day of the ecommerce campaign is 100 sends and 9 bounces.
const notified: Alert[] = [];

const strict = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    alerts({
      notify: (found) => {
        notified.push(...found);
      },
      rules: [bounceRateAbove(0.05, { minSent: 150 })],
    }),
  ],
});

const day = await strict.campaign("cmp_ecom").stats({
  from: "2026-11-13",
  to: "2026-11-13",
});

console.log(
  "sent",
  day.rows[0]?.sent,
  "-> alerts:",
  strict.alerts.evaluate(day.rows).length
);

const both = await strict.campaign("cmp_ecom").stats({
  from: "2026-11-12",
  to: "2026-11-13",
});

console.log(
  "sent",
  both.rows[0]?.sent,
  "-> alerts:",
  strict.alerts.evaluate(both.rows).length
);
```

```text title="Output"
read succeeded: 2 senders
sent 100 -> alerts: 0
sent 200 -> alerts: 1
```

| What happened | What you get | What to do |
| --- | --- | --- |
| `notify` throws or rejects | Nothing. The read succeeds and the alerts are lost. | Make the notifier retry or queue on its own. |
| `notify` is slow | Nothing. The read does not wait for it. | An alert can arrive after the read returns. |
| A rule's `check` throws | The read fails with that error | Guard a rule you write against records it does not expect. |
| The read fails | No rules run | Nothing. A failed read is not an alert. Watch for errors with [hooks](/docs/api/hooks). |
| A row is too small for a rate | No alert | Lower `minSent` if small rows matter to you. |
