---
title: Find the campaigns about to run out of leads
description: 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.
sidebar:
  label: Lead runway
related:
  - /docs/insights/operations
  - /docs/rate-limits
  - /guides/weekly-client-report
  - /guides/diagnose-campaign
---

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.

> **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.

## What you get

```text title="Output"
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

```ts title="runway.ts"
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:

```ts title="seed.ts"
// 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 }),
  ],
};
```

```ts title="run.ts"
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:

```ts title="cost.ts"
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})`
    );
  }
}
```

```text title="Output"
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](/docs/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](/guides/weekly-client-report).

## Point it at your vendor

Swap the adapter. Nothing else changes.

```ts title="outbound.ts"
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.

```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 { 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"));
```

```text title="Output"
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

```ts title="failures.ts"
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);
  }
}
```

```text title="Output"
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.
