Check domain health and bounces on a schedule, and call your notifier
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.
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.
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
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
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
highrisk when a mailbox is on a block list, when SPF, DKIM or MX fails, or when warmup emails land in spam more often thanspam_rate_threshold(default 0.1), pooled over the domain’s mailboxes. It ismediumwhen DMARC fails or a mailbox is disconnected. It isunknown, notlow, when the vendor reports none of the seven checks.notReportedlists 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 leastmin_sentsends (default 100). A campaign with fewer sends goes intooFewSends, and one with no sent or bounce count goes inunknown. 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.
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
domainHealthlisted the senders and again whenbounceWatchlisted the senders of the campaign over the threshold. Deduplicate byruleandsubject, or passdedupe: memoryAlertDedupe()toalerts, 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:
// 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.
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):
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.
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"));
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 tomax_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 tomax_campaigns(default 50). For each campaign over the threshold: one listing of its senders and one stats read per sender, up tomax_senders_per_campaign(default 10). A sender shared by several campaigns is read once. Passinclude_senders: falseto 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
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);
}
}
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
anomaliesorperiodComparisonfrom insights. bounceRateAboveandreplyRateBelowskip rows with fewer thanminSentsends (default 50).- DNS and block list results are the vendor’s own tests. The SDK makes no DNS lookup.
domainHealthreads the firstmax_senderssenders the vendor lists, in the vendor’s order.