---
title: Check domain health and bounces on a schedule, and call your notifier
description: One function that reads sender health and bounce rates and returns a summary, wired to the alerts plugin so your own notifier is called when a read crosses a rule. You schedule it; nothing runs in between.
sidebar:
  label: Deliverability check
related:
  - /docs/insights/senders
  - /docs/plugins/alerts
  - /docs/guides/sender-capacity
  - /docs/guides/diagnose-campaign
---

A failing DKIM record or a list that bounces at 6% costs more every day it goes unseen. `domainHealth` groups your mailboxes by sending domain and gives each a risk level with the reasons: failing DNS records, mailboxes on a block list or disconnected, warmup emails landing in spam. `bounceWatch` lists the campaigns whose bounce rate is over a threshold, with the senders behind them. The `alerts` plugin checks fixed rules against every record a read returns and calls a function you supply.

Nothing here watches anything on its own. The SDK has no background process and stores nothing. A check happens when your scheduler runs this function, and an alert can only fire after a read that the function made. Between two runs, nobody is looking.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## Before you start

- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an Instantly, Smartlead or HeyReach API key. A read-only key is enough.
- A scheduler you already run (cron, a GitHub Actions schedule, a Vercel cron route) and somewhere to send a message.

## What you get

```text title="Output"
Domains:
  go.example.com: high risk, 2 mailboxes
    - DKIM failing
    - 30 of 150 warmup emails landed in spam (0.2, over 0.1)
    - 1 of 2 mailboxes are disconnected
  mail.example.com: low risk, 2 mailboxes

Campaigns over a 3.0% bounce rate:
  Ecommerce ops: 54 of 900 sends bounced (0.06), over the 0.03 threshold.
    cara@go.example.com: 9.2% (46 of 500)

Clean: 1. Too few sends to judge: 1. No bounce count: 0.

Alerts the notifier received:
  sender-not-active memory:acme:snd_dan: Sender is disconnected
  bounce-rate-above memory:acme:cmp_ecom: Bounce rate is 6.0%, above 3.0%
  sender-not-active memory:acme:snd_dan: Sender is disconnected
  bounce-rate-above memory:acme:snd_cara: Bounce rate is 9.2%, above 3.0%
```

The first part is the function's return value. The last part is what the notifier was handed while the function ran.

## Write the check

```ts title="check.ts"
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";

const percent = (rate: number | null): string =>
  rate === null ? "not reported" : `${(rate * 100).toFixed(1)}%`;

/**
 * One pass over domain health and bounces, as plain text. Run it from your
 * scheduler: nothing is read between runs.
 */
export const deliverabilityCheck = async (
  outbound: Outbound & InsightsExtension
): Promise<string> => {
  const domains = await outbound.insights.domainHealth({});
  const bounces = await outbound.insights.bounceWatch({ threshold: 0.03 });

  const lines = ["Domains:"];

  for (const domain of domains.data.domains) {
    lines.push(
      `  ${domain.domain}: ${domain.risk} risk, ${domain.mailboxes.length} mailboxes`,
      ...domain.reasons.map((reason) => `    - ${reason}`)
    );

    // A check the vendor reports for no mailbox was not passed. Say so.
    if (domain.notReported.length > 0) {
      lines.push(`    not reported: ${domain.notReported.join(", ")}`);
    }
  }

  lines.push(
    "",
    `Campaigns over a ${percent(bounces.data.threshold)} bounce rate:`
  );

  for (const campaign of bounces.data.over) {
    lines.push(`  ${campaign.name}: ${campaign.finding}`);

    for (const sender of campaign.senders ?? []) {
      lines.push(
        `    ${sender.address ?? sender.sender}: ${percent(sender.rate)} (${sender.bounced} of ${sender.sent})`
      );
    }
  }

  // Too few sends to judge, or no bounce count at all, is not "clean".
  lines.push(
    "",
    `Clean: ${bounces.data.totals.clean}. Too few sends to judge: ${bounces.data.totals.tooFewSends}. No bounce count: ${bounces.data.totals.unknown}.`
  );

  const notes = [...domains.meta.notes, ...bounces.meta.notes];

  if (notes.length > 0) {
    lines.push(
      "",
      "Read this before relying on the check:",
      ...notes.map((note) => `  - ${note}`)
    );
  }

  return lines.join("\n");
};
```

How the two insights judge:

- **A domain is `high` risk** when a mailbox is on a block list, when SPF, DKIM or MX fails, or when warmup emails land in spam more often than `spam_rate_threshold` (default 0.1), pooled over the domain's mailboxes. It is `medium` when DMARC fails or a mailbox is disconnected. It is `unknown`, not `low`, when the vendor reports none of the seven checks. `notReported` lists the checks the vendor reported for no mailbox of the domain.
- **A campaign is "over"** when bounced divided by sent is above `threshold` (default 0.03) and it has at least `min_sent` sends (default 100). A campaign with fewer sends goes in `tooFewSends`, and one with no sent or bounce count goes in `unknown`. Neither is counted as clean.
- **Senders under a campaign** are counted over everything each sender sent in the period, not that campaign alone. A sender shared by two campaigns shows the same numbers under both.
- **Rates carry an interval.** Each bounce rate and each pooled warmup spam rate has a 95% Wilson interval, wide on few sends.

`from` and `to` on `bounceWatch` limit the days counted. Left out, it counts the vendor's whole history, which is slow to react: on a schedule, pass the last few days.

## Add the notifier

`alerts({ rules, notify })` wraps every read on the instance. After a read returns, each record is checked against each rule, and `notify` is called once with whatever fired. A rule is arithmetic on one record. The built-in rules are `bounceRateAbove`, `replyRateBelow`, `senderNotActive` and `noSends`.

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

import { deliverabilityCheck } from "./check.ts";
import { seed } from "./seed.ts";

// Your notifier. Here it collects; in production it posts to Slack, email
// or a pager. It is called once per read that broke a rule.
const received: Alert[] = [];

const notify = (fired: readonly Alert[]): void => {
  received.push(...fired);
};

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [
    insights(),
    alerts({
      notify,
      rules: [bounceRateAbove(0.03, { minSent: 100 }), senderNotActive()],
    }),
  ],
});

console.log(await deliverabilityCheck(outbound));

console.log("\nAlerts the notifier received:");

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

Three things to know before you wire `notify` to a pager:

- **It is called per read, so the same alert can arrive twice in one run.** Above, the disconnected mailbox fired once when `domainHealth` listed the senders and again when `bounceWatch` listed the senders of the campaign over the threshold. Deduplicate by `rule` and `subject`, or pass `dedupe: memoryAlertDedupe()` to `alerts`, which does that for as long as the instance lives.
- **The SDK stores nothing, so it does not remember yesterday.** A sender that stays disconnected alerts on every run. If you want "only when it changes", keep the last set of alerts in your own store and compare.
- **The rules are separate from the insights' thresholds.** `bounceRateAbove(0.03, { minSent: 100 })` looks at any stats row a read returns, for a campaign or a sender. `bounceWatch({ threshold })` decides what the summary lists. Set both.

## Try it without a key

This seed has two sending domains, four mailboxes and three campaigns:

```ts title="seed.ts"
// Made-up sending setup for the client "acme": two sending domains, one
// healthy and one in trouble, and three campaigns.
import type { MemoryData, MemoryHealth } from "outbound-sdk/memory";

const healthy: MemoryHealth = {
  blacklisted: false,
  connected: true,
  dkim: true,
  dmarc: true,
  mx: true,
  spf: true,
};

const DAY = "2026-11-13";

export const seed: MemoryData = {
  campaigns: [
    { id: "cmp_founders", name: "SaaS founders" },
    { id: "cmp_ecom", name: "Ecommerce ops" },
    { id: "cmp_new", name: "Agency owners" },
  ],
  senders: [
    {
      address: "ana@mail.example.com",
      campaignIds: ["cmp_founders", "cmp_new"],
      health: { ...healthy, warmup: { sent: 200, spam: 4 } },
      id: "snd_ana",
    },
    {
      address: "ben@mail.example.com",
      campaignIds: ["cmp_founders"],
      health: { ...healthy, warmup: { sent: 200, spam: 6 } },
      id: "snd_ben",
    },
    // DKIM fails, and a fifth of its warmup emails land in spam.
    {
      address: "cara@go.example.com",
      campaignIds: ["cmp_ecom"],
      health: { ...healthy, dkim: false, warmup: { sent: 150, spam: 30 } },
      id: "snd_cara",
    },
    // The vendor lost its connection to this mailbox.
    {
      address: "dan@go.example.com",
      campaignIds: ["cmp_ecom"],
      health: { ...healthy, connected: false, dkim: false },
      id: "snd_dan",
      status: "disconnected",
    },
  ],
  stats: [
    // Per campaign.
    {
      campaignId: "cmp_founders",
      counts: { bounced: 14, sent: 1400 },
      date: DAY,
    },
    { campaignId: "cmp_ecom", counts: { bounced: 54, sent: 900 }, date: DAY },
    { campaignId: "cmp_new", counts: { bounced: 3, sent: 40 }, date: DAY },
    // Per sender, over everything the sender sent.
    {
      campaignId: "cmp_founders",
      counts: { bounced: 8, sent: 740 },
      date: DAY,
      senderId: "snd_ana",
    },
    {
      campaignId: "cmp_founders",
      counts: { bounced: 9, sent: 700 },
      date: DAY,
      senderId: "snd_ben",
    },
    {
      campaignId: "cmp_ecom",
      counts: { bounced: 46, sent: 500 },
      date: DAY,
      senderId: "snd_cara",
    },
    {
      campaignId: "cmp_ecom",
      counts: { bounced: 8, sent: 400 },
      date: DAY,
      senderId: "snd_dan",
    },
  ],
};
```

`run.ts` above is the script that printed the output at the top. In it, `go.example.com` is high risk for two reasons and medium for a third. One campaign is over the threshold at 54 of 900, and of its two senders only one is itself over 3%. The campaign with 3 bounces in 40 sends is 7.5%, and is reported as too small to judge.

## Point it at your vendor

Swap the adapter, and replace the notifier with your own.

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

// Replace with a post to Slack, email or a pager. A notifier that throws or
// rejects cannot fail the read it reports on, so log its own failures here.
const notify = (fired: readonly Alert[]): void => {
  for (const alert of fired) {
    console.error(`[${alert.rule}] ${alert.subject}: ${alert.message}`);
  }
};

// Reads INSTANTLY_API_KEY. Swap the import for smartlead() or heyreach().
export const outbound = createOutbound({
  adapter: instantly({ workspaceId: "acme" }),
  // One check may spend at most 300 vendor requests.
  maxRequests: 300,
  plugins: [
    insights(),
    alerts({
      notify,
      rules: [bounceRateAbove(0.03, { minSent: 100 }), senderNotActive()],
    }),
  ],
});
```

Then call `deliverabilityCheck(outbound)` from your scheduler. A crontab line for every morning at 07:00 looks like this (not run by the test suite):

```sh
0 7 * * * cd /srv/outbound && node deliverability-check.js
```

## What each vendor can serve

The same function, run on the in-memory adapter limited to what each vendor's adapter declares. This shows which calls a vendor's adapter would refuse. It does not show what that vendor's API returns.

```ts title="vendors.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";

import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import type { Vendor } from "../_shared/as-vendor.ts";
import { deliverabilityCheck } from "./check.ts";
import { seed } from "./seed.ts";

const tryAs = async (vendor: Vendor): Promise<string> => {
  const outbound = createOutbound({
    adapter: asVendor(vendor, seed, "acme"),
    plugins: [insights()],
  });

  try {
    const check = await deliverabilityCheck(outbound);

    return `${vendor}: ok, ${check.split("\n").length} lines`;
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    return `${vendor}: ${error.code}: ${error.message}`;
  }
};

const lines = await Promise.all(VENDORS.map(tryAs));

console.log(lines.join("\n"));
```

```text title="Output"
instantly: ok, 12 lines
smartlead: ok, 12 lines
heyreach: ok, 12 lines
```

All three declare what the function needs. What each vendor reports inside a health record differs, and the adapters say so in their caveats: Instantly reports no block list state, Smartlead reports no DNS records or block list state, and HeyReach's health is a LinkedIn account's connection state only. Those checks show up in `notReported`.

## What it costs in vendor requests

- `domainHealth`: one listing of senders, then one health read per sender, up to `max_senders` (default 50). A health read is three vendor requests on Instantly, two on Smartlead and one on HeyReach.
- `bounceWatch`: one listing of campaigns, then one stats read per campaign, up to `max_campaigns` (default 50). For each campaign over the threshold: one listing of its senders and one stats read per sender, up to `max_senders_per_campaign` (default 10). A sender shared by several campaigns is read once. Pass `include_senders: false` to skip that part.

`maxRequests` on the instance caps what one run may spend. Past it a call fails with `RateLimited` instead of spending your vendor quota.

## When it cannot answer

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

import { seed } from "./seed.ts";

// 1. A notifier that throws. The read it was reporting on still succeeds,
//    and nobody is told: catch and log inside your notifier.
const broken = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [
    alerts({
      notify: () => {
        throw new Error("Slack is down");
      },
      rules: [senderNotActive()],
    }),
  ],
});

const senders = await broken.senders.listAll().toArray();

console.log("notifier threw:", senders.length, "senders still read");

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [insights()],
});

// 2. More senders than the cap. The answer covers the senders read.
const capped = await outbound.insights.domainHealth({ max_senders: 2 });

console.log("capped:", capped.meta.complete, capped.meta.notes);

// 3. A domain the vendor reports nothing about is `unknown`, not `low`.
const silent = createOutbound({
  adapter: memory({
    data: { senders: [{ address: "eve@new.example.com", id: "snd_eve" }] },
  }),
  plugins: [insights()],
});

const unreported = await silent.insights.domainHealth({});
const [domain] = unreported.data.domains;

console.log("nothing reported:", domain?.risk, domain?.notReported);

// 4. An adapter without stats per sender. The campaigns are still checked;
//    the senders behind them are not, and a note says so.
const noSenderStats = createOutbound({
  adapter: memory({ capabilities: { "stats.bySender": false }, data: seed }),
  plugins: [insights()],
});

const watch = await noSenderStats.insights.bounceWatch({});

console.log(
  "no sender stats:",
  watch.data.over.map((campaign) => campaign.senders),
  watch.meta.notes
);

// 5. An adapter that cannot read sender health refuses domainHealth.
const noHealth = createOutbound({
  adapter: memory({ capabilities: { "senders.health": false }, data: seed }),
  plugins: [insights()],
});

try {
  await noHealth.insights.domainHealth({});
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("no health:", error.code, "-", error.message);
  }
}
```

```text title="Output"
notifier threw: 4 senders still read
capped: false [ 'Stopped at 2 senders; more exist.' ]
nothing reported: unknown [ 'spf', 'dkim', 'dmarc', 'mx', 'connection', 'blocklist', 'warmup' ]
no sender stats: [ null ] [
  "Senders were not checked: the adapter needs stats per sender and the list of a campaign's senders."
]
no health: Unsupported - domain_health is not available: the memory adapter does not support senders.health
```

| What happened | What you get | What to do |
| --- | --- | --- |
| The notifier throws or rejects | The read succeeds. The failure is swallowed and reported nowhere. | Catch and log inside your notifier. A check that returns no alerts is not proof the notifier works. |
| More senders than `max_senders` | `meta.complete` is `false` and a note says where it stopped | Raise the cap (up to 500), or accept a check of the first senders. |
| The vendor reports nothing for a domain | Risk `unknown`, with every check in `notReported` | Check the domain's DNS with another tool. |
| The adapter has no stats per sender, or cannot list a campaign's senders | Campaigns are still checked; `senders` is `null` and a note says why | Find the sender in the vendor tool. |
| The adapter cannot read sender health | `Unsupported` for `domainHealth`, before any request | Run `bounceWatch` alone. |
| The scheduler did not run | Nothing. No read, no alert, no error. | Monitor the scheduled job itself. |

## Limits

- On Instantly, each sender's health read costs three requests and makes Instantly run a live DNS test for that mailbox. Do not schedule the check more often than you need the answer.
- No background watching. An alert fires only after a read your code made, and only for the records that read returned.
- The alert rules see one record at a time. They cannot express "three days in a row" or "up 50% on last week". For trends, use `anomalies` or `periodComparison` from [insights](/docs/insights/operations).
- `bounceRateAbove` and `replyRateBelow` skip rows with fewer than `minSent` sends (default 50).
- DNS and block list results are the vendor's own tests. The SDK makes no DNS lookup.
- `domainHealth` reads the first `max_senders` senders the vendor lists, in the vendor's order.
