Decide whether your senders can carry another campaign
One function that adds up what your active senders can send a day, subtracts what the running campaigns already ask for, and lists the senders and campaigns that make the headline number too generous.
Before you launch another campaign you need one number: how many more sends a day the mailboxes can take. senderCapacity gives the two sides of it: the daily limits of your active senders added up, and the daily limits of your active campaigns added up, with what was really sent over the last days. warmupReadiness lists the senders that are not ready to send, and senderLoad lists senders spread over too many campaigns, senders attached to none, and campaigns resting on one sender.
The subtraction, and the decision, are yours. The SDK returns the counts and says which ones the vendor did not report. It moves no sender and changes no limit.
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.
- The number of sends a day the new campaign should make.
What you get
4 active senders can send 120 a day. Active campaigns ask for 110, which fits. 93 a day were sent on average over the last 7 days.
Headroom is 10 a day. A campaign of 40 a day does not fit: short by 30.
Before you count on that capacity:
- Ecommerce ops asks for 70 a day but its 2 active senders can send 60.
- cara@example.com is not ready: Warmup score 72 is under 90.
- dan@example.com is not ready: Status is paused.
- Ecommerce ops has 3 senders, 1 of them not active.
Active senders attached to no campaign: eve@example.com
Write the plan
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";
const number = (value: number | null): string =>
value === null ? "not reported" : String(value);
/**
* Whether the senders can carry one more campaign of `plannedDaily` sends a
* day, as plain text. The three insights give the numbers; the subtraction
* at the top is this function's own.
*/
export const capacityFor = async (
outbound: Outbound & InsightsExtension,
plannedDaily: number
): Promise<string> => {
const capacity = await outbound.insights.senderCapacity({ days: 7 });
const readiness = await outbound.insights.warmupReadiness({});
const load = await outbound.insights.senderLoad({});
const { daily } = capacity.data.capacity;
const ask = capacity.data.ask?.daily ?? null;
// A limit the vendor does not report is null, never 0. Without both
// numbers there is no headroom to state.
const headroom = daily === null || ask === null ? null : daily - ask;
const lines = [capacity.data.finding];
if (headroom === null) {
lines.push(
`Headroom cannot be stated: capacity is ${number(daily)} and the campaigns' ask is ${number(ask)}.`
);
} else {
lines.push(
`Headroom is ${headroom} a day. A campaign of ${plannedDaily} a day ${
headroom >= plannedDaily
? "fits"
: `does not fit: short by ${plannedDaily - headroom}`
}.`
);
}
lines.push("", "Before you count on that capacity:");
for (const campaign of capacity.data.shortCampaigns ?? []) {
lines.push(` - ${campaign.finding}`);
}
// Capacity adds up every active sender, ready or not.
for (const sender of readiness.data.notReady) {
lines.push(
` - ${sender.address ?? sender.sender} is not ready: ${sender.findings.join("; ")}.`
);
}
for (const sender of load.data.overused) {
lines.push(` - ${sender.finding}`);
}
for (const campaign of load.data.campaigns) {
lines.push(` - ${campaign.finding}`);
}
if (load.data.unused.length > 0) {
lines.push(
"",
`Active senders attached to no campaign: ${load.data.unused
.map((sender) => sender.address ?? sender.sender)
.join(", ")}`
);
}
const notes = [
...capacity.meta.notes,
...readiness.meta.notes,
...load.meta.notes,
];
if (notes.length > 0) {
lines.push(
"",
"Read this before relying on the numbers:",
...notes.map((note) => ` - ${note}`)
);
}
return lines.join("\n");
};
What the numbers mean:
- Capacity is the sum of
dailyLimitover senders whose status isactive. A sender with no reported limit is counted incapacity.withoutLimit, never as 0. Capacity includes active senders that are not ready, which is why the function lists them underneath. - The ask is the sum of the daily limits of active campaigns, read from each campaign’s settings. A campaign with no reported limit is counted in
ask.withoutLimit.askRatioover 1 means the campaigns already ask for more than the senders can send. sends.averageDailyis messages sent over the lastdayscomplete days (default 7, ending yesterday) divided bydays. A day the vendor has no row for counts as no sends.utilizationis that average divided by capacity.shortCampaignsare campaigns whose own active senders cannot cover the campaign’s daily limit. A sender shared with another campaign is counted in full for each, so this check is generous too.- Not ready (
warmupReadiness) means any of: status not active, warmup off, warmup score undermin_score(default 90) or not reported, or no daily limit. A missing score counts as not ready. A LinkedIn seat is judged on its status only. Passcheck_health: trueto also check DNS records and warmup spam, at one more read per mailbox. - Overused (
senderLoad) means attached to more thanmax_campaigns_per_sendercampaigns (default 3). Only campaigns instatusesare counted (default["active"]), so a sender used only by a paused campaign shows as unused.
In the output above, headroom is 120 minus 110. But one of the four senders behind the 120 has a warmup score of 72, and one campaign is already short on its own senders. The honest answer to “can we add 40 a day” is no, and the unused mailbox at the bottom is where to start.
Try it without a key
This seed has five mailboxes of 30 sends a day each and two running campaigns:
// Made-up senders and campaigns for the client "acme": five mailboxes, two
// running campaigns and a week of sends.
import type { MemoryData, MemoryStats } from "outbound-sdk/memory";
const DAY = 86_400_000;
const TODAY = Date.parse("2026-11-14T00:00:00.000Z");
const week = (campaignId: string, sent: number): MemoryStats[] =>
Array.from({ length: 7 }, (_, index) => ({
campaignId,
counts: { sent },
date: new Date(TODAY - (index + 1) * DAY).toISOString().slice(0, 10),
}));
export const seed: MemoryData = {
campaigns: [
{
id: "cmp_founders",
name: "SaaS founders",
settings: { dailyLimit: 40 },
},
{ id: "cmp_ecom", name: "Ecommerce ops", settings: { dailyLimit: 70 } },
],
senders: [
{
address: "ana@example.com",
campaignIds: ["cmp_founders", "cmp_ecom"],
dailyLimit: 30,
id: "snd_ana",
warmup: { enabled: true, score: 98 },
},
{
address: "ben@example.com",
campaignIds: ["cmp_founders"],
dailyLimit: 30,
id: "snd_ben",
warmup: { enabled: true, score: 95 },
},
// Active, but its warmup score is under the bar.
{
address: "cara@example.com",
campaignIds: ["cmp_ecom"],
dailyLimit: 30,
id: "snd_cara",
warmup: { enabled: true, score: 72 },
},
// Paused in the vendor tool.
{
address: "dan@example.com",
campaignIds: ["cmp_ecom"],
dailyLimit: 30,
id: "snd_dan",
status: "paused",
warmup: { enabled: true, score: 96 },
},
// Ready, and attached to no campaign.
{
address: "eve@example.com",
dailyLimit: 30,
id: "snd_eve",
warmup: { enabled: true, score: 97 },
},
],
stats: [...week("cmp_founders", 38), ...week("cmp_ecom", 55)],
};
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { capacityFor } from "./plan.ts";
import { seed } from "./seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
// Can the senders carry a new campaign of 40 sends a day?
console.log(await capacityFor(outbound, 40));
That is the script that printed the plan above.
Point it at your vendor
Swap the adapter. Nothing else changes.
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
// Reads INSTANTLY_API_KEY. Swap the import for smartlead() or heyreach().
export const outbound = createOutbound({
adapter: instantly({ workspaceId: "acme" }),
// The three insights together may spend at most 150 vendor requests.
maxRequests: 150,
plugins: [insights()],
});
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 { capacityFor } from "./plan.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 plan = await capacityFor(outbound, 40);
const [, headroom] = plan.split("\n");
return `${vendor}: ${headroom}`;
} 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: Headroom is 10 a day. A campaign of 40 a day does not fit: short by 30.
smartlead: Headroom is 10 a day. A campaign of 40 a day does not fit: short by 30.
heyreach: Headroom cannot be stated: capacity is 120 and the campaigns' ask is not reported.
The HeyReach adapter cannot read campaign settings, because HeyReach puts sending limits on the LinkedIn account and not on the campaign. senderCapacity still returns capacity and sends; ask, askRatio, shortCampaigns and uncheckedCampaigns are null, and a note says why. The function prints that instead of a headroom it cannot compute.
What it costs in vendor requests
senderCapacity: one listing of senders (up tomax_senders, default 200), one listing of active campaigns (up tomax_campaigns, default 25), then for each campaign one read of its settings and one listing of its senders, and one read of daily stats. When the adapter has no workspace stats, daily stats cost one read per campaign instead.warmupReadiness: one listing of senders (up tomax_senders, default 100). Withcheck_health, one health read per mailbox more.senderLoad: one listing of senders, one listing of campaigns per status, then one listing of senders per campaign.
The function above reads the senders three times and each campaign’s senders twice, because each insight makes its own reads. If that matters, put the cache plugin on the instance. On Instantly, listing one campaign’s senders reads every mailbox in the workspace at two requests per page, so the per-campaign part is the expensive one.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
// 1. No sender reports a daily limit. Capacity is unknown, not 0.
const noLimits = createOutbound({
adapter: memory({
data: { senders: [{ address: "ana@example.com", id: "snd_ana" }] },
}),
plugins: [insights()],
});
const unknown = await noLimits.insights.senderCapacity({});
console.log(
"no limits:",
unknown.data.capacity.daily,
"-",
unknown.data.finding
);
// 2. An adapter that cannot read campaign settings. Capacity is still
// reported; what the campaigns ask for is null, and a note says why.
const noSettings = createOutbound({
adapter: memory({
capabilities: { "campaigns.settings": false },
data: seed,
}),
plugins: [insights()],
});
const partial = await noSettings.insights.senderCapacity({});
console.log("no settings:", partial.data.ask, partial.meta.notes);
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
// 3. More campaigns than the cap. The ask covers the campaigns read.
const capped = await outbound.insights.senderCapacity({ max_campaigns: 1 });
console.log(
"capped:",
capped.meta.complete,
capped.data.ask?.daily,
capped.meta.notes
);
// 4. Input outside its range is refused before any request.
try {
await outbound.insights.senderCapacity({ days: 0 });
} catch (error) {
if (OutboundError.is(error)) {
console.log("bad input:", error.code, "-", error.message);
}
}
// 5. An adapter that cannot list senders refuses all three insights.
const noSenders = createOutbound({
adapter: memory({ capabilities: { "senders.list": false }, data: seed }),
plugins: [insights()],
});
try {
await noSenders.insights.senderCapacity({});
} catch (error) {
if (OutboundError.is(error)) {
console.log("no senders:", error.code, "-", error.message);
}
}
no limits: null - No active sender reports a daily limit, so capacity is unknown.
no settings: null [
'The daily ask and the campaigns short of senders were left out: the adapter cannot read campaign settings.'
]
capped: false 40 [ 'Stopped at 1 campaigns; more exist.' ]
bad input: Invalid - sender_capacity: days Too small: expected number to be >=1
no senders: Unsupported - sender_capacity is not available: the memory adapter does not support senders.list
| What happened | What you get | What to do |
|---|---|---|
| No active sender reports a daily limit | capacity.daily is null and the finding says capacity is unknown |
Read the limits in the vendor tool. Do not treat it as 0. |
| The adapter cannot read campaign settings | ask is null, with a note |
State capacity and recent sends only, as the function does. |
More active campaigns than max_campaigns |
meta.complete is false; the ask covers the campaigns read, so it is too low |
Raise the cap (up to 200). |
| Input out of range | Invalid, before any request |
Fix the value. |
| The adapter cannot list senders | Unsupported, before any request |
None of the three insights can run. |
| The adapter cannot list a campaign’s senders | senderLoad is Unsupported; senderCapacity returns shortCampaigns null with a note |
Use senderCapacity and warmupReadiness alone. |
Limits
- Headroom is limits minus limits. It says nothing about whether a vendor or a mailbox provider will let that volume through.
- Capacity counts every active sender in full, ready or not, and mixes mailboxes and LinkedIn seats in one total.
capacity.mailboxDailyandcapacity.linkedinDailysplit it. - A campaign whose limit or whose sender’s limit is not reported is left out of
shortCampaignsand counted inuncheckedCampaigns. senderCapacitylooks atactivecampaigns only. A paused campaign you plan to resume is not in the ask.- When the average daily sends come from the campaigns read and not the workspace, a note says so and the average is a floor.