Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

Find the campaigns about to run out of leads

One function that forecasts every active campaign, sorts them by days of leads left and flags the ones under your threshold, with what the loop costs in vendor requests and what the estimate assumes.

A campaign that runs out of leads stops sending without an error. pipelineForecast counts one campaign’s leads by status, takes the ones still active as “remaining”, divides by the number of new leads contacted per day over the last week, and projects the replies still to come at the campaign’s reply rate so far.

It answers for one campaign per call, and it reads that campaign’s leads to count them, up to a cap. This guide puts it in a loop over the active campaigns and shows what that loop costs. Everything after the lead counts is an estimate, and the answer lists its own assumptions. Nothing is added, paused or changed.

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.

What you get

Lead runway for 4 active campaigns

ADD LEADS Ecommerce ops: 0 leads left, about 0 days left at 30 new leads a day
    expect about 0 more replies (0 to 0)
ADD LEADS SaaS founders: 40 leads left, about 2 days left at 20 new leads a day
    expect about 6 more replies (5 to 7)
Agency owners: 400 leads left, about 16 days left at 25 new leads a day
    expect about 48 more replies (38 to 60)
Fintech CFOs: 120 leads left, days left unknown
    replies cannot be estimated yet

Every figure after the lead counts is an estimate. It assumes:
  - Every remaining lead is contacted.
  - Remaining leads reply at the rate the campaign has had so far; leads already partway through the sequence may reply less.
  - The daily pace stays what it was over the recent days. Days with no report count as no sends.

Vendor requests spent: 0

“Vendor requests spent” is 0 here because the in-memory adapter makes no request. On a real adapter it is the sum of meta.requests over the loop.

Write the loop

import type { Outbound } from "outbound-sdk";
import type {
  InsightsExtension,
  PipelineForecast,
} from "outbound-sdk/insights";

export interface RunwayOptions {
  /** A campaign with this many days of leads left, or fewer, is flagged. */
  readonly warnDays?: number;
  /** The most active campaigns to forecast. Each is one insight call. */
  readonly maxCampaigns?: number;
  /** The most leads to read per campaign. A page of leads is one request. */
  readonly maxLeads?: number;
}

const whole = (value: number | null): string =>
  value === null ? "unknown" : String(Math.round(value));

const days = (forecast: PipelineForecast): string =>
  forecast.daysLeft === null
    ? "days left unknown"
    : `about ${forecast.daysLeft} days left at ${forecast.dailyContactRate} new leads a day`;

const replies = ({ expectedReplies }: PipelineForecast): string =>
  expectedReplies.expected === null
    ? "replies cannot be estimated yet"
    : `expect about ${whole(expectedReplies.expected)} more replies (${whole(expectedReplies.low)} to ${whole(expectedReplies.high)})`;

/** The active campaigns by how soon they run out of leads, as plain text. */
export const leadRunway = async (
  outbound: Outbound & InsightsExtension,
  { maxCampaigns = 25, maxLeads = 2000, warnDays = 5 }: RunwayOptions = {}
): Promise<string> => {
  const walk = outbound.campaigns.listAll({
    max: maxCampaigns,
    status: "active",
  });

  const campaigns = await walk.toArray();
  const forecasts: PipelineForecast[] = [];
  const notes = new Set<string>();
  let requests = 0;

  // pipelineForecast is one campaign per call, so this is one call each.
  for (const campaign of campaigns) {
    // oxlint-disable-next-line no-await-in-loop -- one campaign at a time keeps the vendor's rate budget predictable.
    const { data, meta } = await outbound.insights.pipelineForecast({
      campaign: campaign.ref,
      days: 7,
      max_leads: maxLeads,
    });

    forecasts.push(data);
    requests += meta.requests;

    for (const note of meta.notes) {
      notes.add(`${data.name}: ${note}`);
    }
  }

  // Soonest to run out first; a campaign with no pace to divide by goes last.
  const soonest = forecasts.toSorted(
    (left, right) =>
      (left.daysLeft ?? Number.POSITIVE_INFINITY) -
      (right.daysLeft ?? Number.POSITIVE_INFINITY)
  );

  const lines = [`Lead runway for ${campaigns.length} active campaigns`, ""];

  for (const forecast of soonest) {
    const flagged = forecast.daysLeft !== null && forecast.daysLeft <= warnDays;

    lines.push(
      `${flagged ? "ADD LEADS " : ""}${forecast.name}: ${forecast.leadsComplete ? "" : "at least "}${forecast.remaining} leads left, ${days(forecast)}`,
      `    ${replies(forecast)}`
    );
  }

  requests += walk.meta?.requests ?? 0;

  if (walk.truncated) {
    notes.add(`Stopped at ${maxCampaigns} campaigns; more are active.`);
  }

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

  lines.push(
    "",
    "Every figure after the lead counts is an estimate. It assumes:",
    ...(soonest[0]?.assumptions.slice(0, 3) ?? []).map((text) => `  - ${text}`),
    "",
    `Vendor requests spent: ${requests}`
  );

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

What the forecast holds, and what it assumes:

  • remaining is a count. It is the leads whose status is active: not replied, bounced, unsubscribed, paused or completed. It is exact when leadsComplete is true, and a minimum when max_leads stopped the read.
  • dailyContactRate is new leads contacted over the last days complete days (default 7, ending yesterday), divided by days. A day with no report counts as no sends, so a campaign that paused for half the week has half the pace.
  • daysLeft is remaining divided by that pace. It is null when the vendor reports no daily contacted count or the pace is 0. It is calendar days at last week’s pace, weekends included.
  • expectedReplies is remaining times the campaign’s reply rate over its whole history, with low and high from the 95% Wilson interval of that rate. expectedPositive does the same for positive replies.
  • assumptions says what this takes for granted: every remaining lead is contacted; remaining leads reply at the rate so far, though leads already partway through the sequence may reply less; and the pace stays what it was.

The function sorts by daysLeft and puts campaigns with no pace last. A null there is “unknown”, and sorting it as 0 would flag a campaign that has not started.

Try it without a key

This seed has four active campaigns and one paused one. The leads are generated so each campaign has a different number left:

// Made-up campaigns for the client "acme", each with a different number of
// leads left to contact.
import type { LeadStatus } from "outbound-sdk";
import type { MemoryData, MemoryLead, MemoryStats } from "outbound-sdk/memory";

const DAY = 86_400_000;
const TODAY = Date.parse("2026-11-14T00:00:00.000Z");

const daily = (
  campaignId: string,
  counts: MemoryStats["counts"]
): MemoryStats[] =>
  Array.from({ length: 21 }, (_, index) => ({
    campaignId,
    counts,
    date: new Date(TODAY - (index + 1) * DAY).toISOString().slice(0, 10),
  }));

const leads = (
  campaignId: string,
  status: LeadStatus,
  count: number
): MemoryLead[] =>
  Array.from({ length: count }, (_, index) => ({
    campaignId,
    email: `${campaignId}.${status}.${index}@example.com`,
    id: `${campaignId}_${status}_${index}`,
    status,
  }));

export const seed: MemoryData = {
  campaigns: [
    { id: "cmp_agencies", name: "Agency owners" },
    { id: "cmp_founders", name: "SaaS founders" },
    { id: "cmp_ecom", name: "Ecommerce ops" },
    // Launched today: leads loaded, nothing sent yet.
    { id: "cmp_new", name: "Fintech CFOs" },
    { id: "cmp_paused", name: "Old list", status: "paused" },
  ],
  leads: [
    ...leads("cmp_agencies", "active", 400),
    ...leads("cmp_agencies", "replied", 40),
    ...leads("cmp_founders", "active", 40),
    ...leads("cmp_founders", "completed", 300),
    ...leads("cmp_founders", "replied", 60),
    ...leads("cmp_ecom", "completed", 580),
    ...leads("cmp_ecom", "bounced", 30),
    ...leads("cmp_ecom", "replied", 20),
    ...leads("cmp_new", "active", 120),
    ...leads("cmp_paused", "active", 90),
  ],
  stats: [
    ...daily("cmp_agencies", { contacted: 25, replied: 3, sent: 80 }),
    ...daily("cmp_founders", { contacted: 20, replied: 3, sent: 60 }),
    ...daily("cmp_ecom", { contacted: 30, replied: 1, sent: 90 }),
  ],
};
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

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

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

console.log(await leadRunway(outbound, { warnDays: 5 }));

That is the script that printed the runway above. SaaS founders has 40 active leads and contacted 20 a day last week, so about 2 days. Its 63 replies over 420 leads contacted is 15%, so about 6 more replies. Fintech CFOs has leads and no sends yet, so there is no pace and no rate to project.

What it costs in vendor requests

One forecast is one read of the campaign, one request per page of its leads up to max_leads (default 2,000), and two stats reads. The leads are the part that grows. This sample asks each real adapter what it declares for a listing of leads, without calling it:

import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { instantly } from "outbound-sdk/instantly";
import { smartlead } from "outbound-sdk/smartlead";

// Real adapters, constructed and never called. `estimate` reads the costs an
// adapter declares and makes no request.
const vendors = [
  createOutbound({ adapter: instantly() }),
  createOutbound({ adapter: smartlead() }),
  createOutbound({ adapter: heyreach() }),
];

for (const outbound of vendors) {
  for (const leads of [500, 2000]) {
    const { basis, requests } = outbound.estimate({
      action: "leads.list",
      records: leads,
    });

    console.log(
      `${outbound.provider}: ${requests} requests to read ${leads} leads of one campaign (${basis})`
    );
  }
}
instantly: 5 requests to read 500 leads of one campaign (5 page(s) of 100 at 1 request(s) each, plus 0 per record)
instantly: 20 requests to read 2000 leads of one campaign (20 page(s) of 100 at 1 request(s) each, plus 0 per record)
smartlead: 5 requests to read 500 leads of one campaign (5 page(s) of 100 at 1 request(s) each, plus 0 per record)
smartlead: 20 requests to read 2000 leads of one campaign (20 page(s) of 100 at 1 request(s) each, plus 0 per record)
heyreach: 5 requests to read 500 leads of one campaign (5 page(s) of 100 at 1 request(s) each, plus 0 per record)
heyreach: 20 requests to read 2000 leads of one campaign (20 page(s) of 100 at 1 request(s) each, plus 0 per record)

So the loop costs, at most, one listing of campaigns plus, for each active campaign, the pages of its leads and three more reads. Reading a campaign is three vendor requests on Instantly, which makes it five. For 25 campaigns of 2,000 leads or more that is about 600 requests. Smartlead’s adapter allows 60 requests a minute, so that run takes about ten minutes: the SDK waits out the vendor’s limit for you and does not fail, unless you set maxRequests or a deadline. See rate limits.

Three ways to spend less:

  • Lower maxLeads. A campaign with more leads than the cap reports “at least” that many remaining, which is enough to know it is not about to run out.
  • Lower maxCampaigns, or run the loop for the campaigns you are worried about.
  • Use clientReport, which runs this same forecast for every active campaign inside one call, reads at most 500 leads per campaign by default, and lists the campaigns under runway_days. See the weekly client report.

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" }),
  // Enough for 25 campaigns read with maxLeads 500: 25 x (3 + 5 + 2), plus 3
  // for the list of campaigns.
  maxRequests: 260,
  plugins: [insights()],
});

maxRequests caps what one run may spend. If the cap is reached the call fails with RateLimited instead of spending your vendor quota.

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 { leadRunway } from "./runway.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 runway = await leadRunway(outbound);

    return `${vendor}: ok, ${runway.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, 17 lines
smartlead: ok, 17 lines
heyreach: ok, 17 lines

pipelineForecast needs leads.byCampaign and stats.campaign, which all three declare. daysLeft also needs stats.byDay and a daily contacted count; without them it is null.

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";

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

// 1. More leads than the cap. The count of leads left is a minimum, and the
//    finding says "at least".
const capped = await outbound.insights.pipelineForecast({
  campaign: "cmp_agencies",
  max_leads: 100,
});

console.log(
  "capped:",
  capped.data.leadsComplete,
  capped.meta.complete,
  "-",
  capped.data.finding
);

// 2. A campaign with no sends yet. There is no pace to divide by and no
//    reply rate to project, so both are null. Neither is 0.
const fresh = await outbound.insights.pipelineForecast({ campaign: "cmp_new" });

console.log(
  "nothing sent:",
  fresh.data.daysLeft,
  fresh.data.expectedReplies.expected,
  "-",
  fresh.data.finding
);

// 3. An adapter without stats by day. Leads are still counted; days left
//    are not estimated, and a note says so.
const noDaily = createOutbound({
  adapter: memory({ capabilities: { "stats.byDay": false }, data: seed }),
  plugins: [insights()],
});

const undated = await noDaily.insights.pipelineForecast({
  campaign: "cmp_founders",
});

console.log(
  "no daily stats:",
  undated.data.remaining,
  undated.data.daysLeft,
  undated.meta.notes
);

// 4. A campaign that does not exist.
try {
  await outbound.insights.pipelineForecast({ campaign: "cmp_missing" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("unknown campaign:", error.code, "-", error.message);
  }
}

// 5. An adapter that cannot list a campaign's leads refuses the forecast.
const noLeads = createOutbound({
  adapter: memory({ capabilities: { "leads.byCampaign": false }, data: seed }),
  plugins: [insights()],
});

try {
  await noLeads.insights.pipelineForecast({ campaign: "cmp_founders" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("no leads:", error.code, "-", error.message);
  }
}
capped: false false - At least 100 leads remain; expect about 12 replies (between 9.49 and 15.06). This is an estimate.
nothing sent: null null - 120 leads remain; replies cannot be estimated because the vendor reports no reply rate yet.
no daily stats: 40 null [
  'Days of sending left were not estimated: the adapter has no stats by day.'
]
unknown campaign: NotFound - Campaign cmp_missing was not found
no leads: Unsupported - pipeline_forecast is not available: the memory adapter does not support leads.byCampaign
What happened What you get What to do
More leads than max_leads leadsComplete and meta.complete are false; remaining is a minimum and the finding says “at least” Raise max_leads (up to 50,000) if you need the exact count.
Nothing sent yet daysLeft and expectedReplies.expected are null Nothing to project from. It is not 0 days.
The adapter has no stats by day Leads are counted; daysLeft is null and a note says why Divide remaining by your own daily volume.
A campaign that does not exist NotFound Check the id.
The adapter cannot list a campaign’s leads Unsupported, before any request The forecast cannot run on that adapter.
More active campaigns than maxCampaigns The loop forecasts the first ones and adds a note Raise it, or page through the campaigns yourself.

Limits

  • It is an estimate. The reply projection assumes untouched leads reply like the ones before them, and the days assume last week’s pace.
  • “Remaining” is by lead status. A lead the vendor still marks active but that has received every step counts as remaining until the vendor marks it completed.
  • With the cap reached, remaining counts only the active leads among the leads read, in the vendor’s order. It can be far under the real number.
  • The reply rate is over the campaign’s whole history, not the recent days.
  • The loop is sequential on purpose, so the vendor’s rate budget is spent one campaign at a time.

Was this page helpful?