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

Write one function that runs on Instantly, Smartlead and HeyReach

A function that reads outbound.capabilities before each call, says what a vendor cannot serve, and reports how each answer was produced, run as each vendor with the output recorded.

The three vendors do not offer the same reads. HeyReach has no per-step stats and no readable campaign settings. Smartlead cannot filter campaigns by status on its side. Code written against one vendor breaks, or quietly reports less, on another.

The SDK gives you three things to write against instead of a vendor: outbound.capabilities, a flat map of what this instance can read; Unsupported, thrown before any request when a call is not in that map; and meta.evidence on every answer, which says whether a number came from the vendor or was worked out. This guide builds one function on those and runs it as each vendor.

The SDK does not fill a gap with a guess. A read the vendor lacks is refused, and a count the vendor does not report is null.

Before you start

  • Node 22 or later.
  • The code runs on the in-memory adapter, so it needs no key. Each sample is a file 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

The same function on seeded data, limited in turn to what each vendor’s adapter declares.

instantly
  active campaigns: 1 (campaigns.status is "server", evidence exact)
  totals: sent 420, replied 21, opened not reported (evidence exact)
  by step: step 0 sent 140, reply rate 10.0% of sent
  by step: step 1 sent 280, reply rate 2.5% of sent
  settings: daily limit 60, open tracking false
  positive replies: 1
smartlead
  active campaigns: 1 (campaigns.status is "client", evidence derived)
  totals: sent 420, replied 21, opened not reported (evidence exact)
  by step: step 0 sent 140, reply rate 10.0% of sent
  by step: step 1 sent 280, reply rate 2.5% of sent
  settings: daily limit 60, open tracking false
  positive replies: 1
heyreach
  active campaigns: 1 (campaigns.status is "server", evidence exact)
  totals: sent 420, replied 21, opened not reported (evidence exact)
  by step: this vendor has no per-step stats
  settings: this vendor does not expose campaign settings
  replies: 2 (this vendor does not categorise replies)

The numbers are the seed’s, so they match across vendors. What differs is what each vendor’s adapter can serve and how:

  • Smartlead lists the same one active campaign, but with evidence derived: its API has no status filter, so the SDK read every campaign and filtered after.
  • HeyReach has no per-step stats, no campaign settings and no reply categories. The function says so in three lines and still returns the rest.
  • opened is “not reported” on all three. The seed has no open count, so the count is null. The function prints that, not 0.

This run shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns.

The function

import { computeRates } from "outbound-sdk";
import type { Outbound } from "outbound-sdk";

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

// A count the vendor does not report is null, never 0.
const count = (value: number | null): string =>
  value === null ? "not reported" : String(value);

/**
 * A short account of one campaign that runs on any adapter. Each part first
 * reads `outbound.capabilities` and says so when the vendor cannot serve it.
 */
export const campaignBrief = async (
  outbound: Outbound,
  campaignId: string
): Promise<string[]> => {
  const { capabilities } = outbound;
  const campaign = outbound.campaign(campaignId);
  const lines: string[] = [];

  // 1. A filter has a grade. "server": the vendor filters. "client": the SDK
  //    reads every campaign and filters after, and the answer says `derived`.
  const walk = outbound.campaigns.listAll({ status: "active" });
  const active = await walk.toArray();

  lines.push(
    `active campaigns: ${active.length} (campaigns.status is "${capabilities["campaigns.status"]}", evidence ${walk.meta?.evidence})`
  );

  // 2. A stat has a grade too: "exact" from the vendor, "derived" when the
  //    adapter computes it, false when there is nothing to read.
  if (capabilities["stats.campaign"] === false) {
    lines.push("totals: this vendor has no campaign stats");
  } else {
    const stats = await campaign.stats();
    const [total] = stats.rows;

    lines.push(
      `totals: sent ${count(total?.sent ?? null)}, replied ${count(total?.replied ?? null)}, opened ${count(total?.opened ?? null)} (evidence ${stats.meta.evidence})`
    );
  }

  if (capabilities["stats.byStep"] === false) {
    lines.push("by step: this vendor has no per-step stats");
  } else {
    const { rows } = await campaign.stats({ scope: "step" });

    for (const row of rows) {
      // computeRates returns null for a rate whose counts are not reported.
      const { replyRate } = computeRates(row);

      lines.push(
        `by step: step ${row.stepIndex} sent ${count(row.sent)}, reply rate ${percent(replyRate.value)} of ${replyRate.basis}`
      );
    }
  }

  // 3. A plain yes or no.
  if (capabilities["campaigns.settings"]) {
    const settings = await campaign.settings();

    lines.push(
      `settings: daily limit ${count(settings.dailyLimit)}, open tracking ${settings.tracking.opens}`
    );
  } else {
    lines.push("settings: this vendor does not expose campaign settings");
  }

  // 4. Reply categories come from the vendor or not at all.
  if (capabilities["replies.category"] === "vendor") {
    const positive = await outbound.replies
      .listAll({ campaignId, category: "positive" })
      .toArray();

    lines.push(`positive replies: ${positive.length}`);
  } else {
    const replies = await outbound.replies.listAll({ campaignId }).toArray();

    lines.push(
      `replies: ${replies.length} (this vendor does not categorise replies)`
    );
  }

  return lines;
};

It uses four kinds of capability value:

Kind Values Branch on it when
Filter grade, such as campaigns.status, leads.since, replies.since server, client, false client matters to you: the SDK reads everything and filters after, which costs more requests and marks the answer derived. false means the filter is refused.
Stat grade, such as stats.campaign, stats.byStep, stats.bySender exact, derived, false You must not present a computed number as the vendor’s. derived means the adapter worked the number out, for example by summing daily rows.
Yes or no, such as campaigns.settings, leads.list true, false The read exists or it does not.
A source, such as replies.category vendor, false Categories come from the vendor or are absent. The SDK never infers one.

Every value is false or something truthy, so a plain if works on any key. Compare against a grade only when the grade changes what you do.

Three habits in the function carry over to any portable code:

  • Ask before the call, not after. The check costs nothing: capabilities is data on the instance.
  • Carry meta.evidence to the reader. exact is the vendor’s number. derived was computed or filtered by the SDK. sampled is built on part of the data.
  • Print null as “not reported”. A null count means the vendor did not say. A rate built on it is null too, which computeRates handles.

Try it without a key

// Made-up data: three campaigns in different states, a week of results for
// the active one in total and by step, its settings, and two replies.
import type { MemoryData, MemoryStats } from "outbound-sdk/memory";

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

const day = (daysAgo: number): string =>
  new Date(TODAY - daysAgo * DAY).toISOString().slice(0, 10);

const week = (counts: MemoryStats["counts"]): MemoryStats[] =>
  Array.from({ length: 7 }, (_, index) => ({
    campaignId: "cmp_founders",
    counts,
    date: day(index + 1),
  }));

const weekOfStep = (
  stepIndex: number,
  counts: MemoryStats["counts"]
): MemoryStats[] => week(counts).map((row) => ({ ...row, stepIndex }));

export const seed: MemoryData = {
  campaigns: [
    {
      id: "cmp_founders",
      name: "SaaS founders",
      settings: { dailyLimit: 60, stopOnReply: true, trackOpens: false },
    },
    { id: "cmp_agencies", name: "Agency owners", status: "paused" },
    { id: "cmp_spring", name: "Spring webinar", status: "completed" },
  ],
  messages: [
    {
      campaignId: "cmp_founders",
      category: "positive",
      direction: "in",
      id: "msg_1",
      sentAt: "2026-11-12T09:30:00.000Z",
    },
    {
      campaignId: "cmp_founders",
      category: "negative",
      direction: "in",
      id: "msg_2",
      sentAt: "2026-11-13T15:05:00.000Z",
    },
  ],
  stats: [
    // 60 sends a day to 20 new people, 3 replies. No open count: open
    // tracking is off for this campaign.
    ...week({ contacted: 20, replied: 3, sent: 60 }),
    // The same sends by step: 20 first emails and 40 follow-ups a day.
    ...weekOfStep(0, { replied: 2, sent: 20 }),
    ...weekOfStep(1, { replied: 1, sent: 40 }),
  ],
};
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";

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

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

const lines = await campaignBrief(outbound, "cmp_founders");

console.log(lines.join("\n"));
active campaigns: 1 (campaigns.status is "server", evidence exact)
totals: sent 420, replied 21, opened not reported (evidence exact)
by step: step 0 sent 140, reply rate 10.0% of sent
by step: step 1 sent 280, reply rate 2.5% of sent
settings: daily limit 60, open tracking false
positive replies: 1

The in-memory adapter declares every capability, so every branch takes the supported path. Step 0 has 2 replies a day from 20 sends, so 10.0%; step 1 has 1 from 40, so 2.5%. No contacted count is seeded per step, so the rate is on sent and the line says so.

Run it as each vendor

The in-memory adapter takes a capabilities option. Pass it what a real adapter declares and it refuses what that adapter would refuse. That is all the shared helper does.

import { createOutbound } from "outbound-sdk";

import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { campaignBrief } from "./portable.ts";
import { seed } from "./seed.ts";

for (const vendor of VENDORS) {
  const outbound = createOutbound({ adapter: asVendor(vendor, seed, "acme") });

  // oxlint-disable-next-line no-await-in-loop -- one vendor at a time keeps the output in order.
  const lines = await campaignBrief(outbound, "cmp_founders");

  console.log(`${vendor}\n  ${lines.join("\n  ")}`);
}

Use the same option in your own tests to reach the unsupported branch of your code without a second vendor account: memory({ capabilities: { "stats.byStep": false }, data }).

The stand-in has one blind spot. A filter graded client is applied by the SDK, so the derived on the Smartlead line above is real. A stat graded derived is computed inside the vendor’s adapter, which the stand-in does not run, so it reports seeded stats as exact. Instantly declares stats.bySender as derived; this page does not show that case.

Point it at your vendor

Pass an instance built on instantly(), smartlead() or heyreach(). The function does not change. To pick the vendor from the environment, fromEnv() from outbound-sdk/loader builds the adapter named by OUTBOUND_SDK_PROVIDER.

What it costs in vendor requests

A client filter costs the whole listing, because the vendor is asked for everything. outbound.estimate says what a listing would cost before you make it. It is arithmetic on the costs each adapter declares, and makes no request.

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

// Real adapters, built to read the costs they declare. `estimate` is
// arithmetic on those declarations and makes no request.
const ADAPTERS: Adapter[] = [instantly(), smartlead(), heyreach(), memory()];

for (const adapter of ADAPTERS) {
  const outbound = createOutbound({ adapter });

  console.log(outbound.provider);

  for (const action of ["campaigns.list", "leads.list", "replies.list"]) {
    const { basis, requests } = outbound.estimate({ action, records: 1000 });

    console.log(`  ${action}, 1000 records: ${requests} (${basis})`);
  }
}
instantly
  campaigns.list, 1000 records: 30 (10 page(s) of 100 at 3 request(s) each, plus 0 per record)
  leads.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
  replies.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
smartlead
  campaigns.list, 1000 records: null (1 request(s) per page)
  leads.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
  replies.list, 1000 records: 50 (50 page(s) of 20 at 1 request(s) each, plus 0 per record)
heyreach
  campaigns.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
  leads.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
  replies.list, 1000 records: 10 (10 page(s) of 100 at 1 request(s) each, plus 0 per record)
memory
  campaigns.list, 1000 records: null (memory declares no cost for campaigns.list)
  leads.list, 1000 records: null (memory declares no cost for leads.list)
  replies.list, 1000 records: null (memory declares no cost for replies.list)
  • Listing 1,000 campaigns on Instantly is 30 requests, not 10: each page also reads tag assignments and tag names.
  • Replies on Smartlead come 20 to a page, so 1,000 replies are 50 requests.
  • requests is null when the cost cannot be known up front: Smartlead returns every campaign in one response with no page size, and the in-memory adapter declares no costs.
  • estimate does not read capabilities. HeyReach gets a number for leads.list although it refuses a listing of every lead; the cost there is for one campaign’s leads. Check the capability first.

When it cannot answer

A call the instance does not declare is refused before any request. The sample proves it with a real HeyReach adapter and a fetch that counts.

import { createOutbound, OutboundError } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { memory } from "outbound-sdk/memory";

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

// A real HeyReach adapter with a `fetch` that counts. Nothing below reaches
// it: a call the adapter does not declare is refused before any request.
let requests = 0;

const outbound = createOutbound({
  adapter: heyreach(),
  fetch: () => {
    requests += 1;

    return Promise.resolve(Response.json({}));
  },
});

// Some refusals are thrown by the call itself and some reject the promise it
// returns, so the call is made inside the function that catches.
const refused = async (
  label: string,
  read: () => Promise<object>
): Promise<void> => {
  try {
    await read();
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(`${label}: ${error.code}, permanent ${error.permanent}`);
    console.log(`  ${error.message}`);
  }
};

// 1. A read the vendor does not have.
await refused("settings", () => outbound.campaign("274079").settings());

// 2. A stat scope the vendor does not report.
await refused("by step", () =>
  outbound.campaign("274079").stats({ scope: "step" })
);

// 3. A filter the vendor cannot apply. It is refused, not ignored.
await refused("by category", () =>
  outbound.replies.list({ category: "positive" })
);

// 4. A listing the vendor does not offer in that form.
await refused("every lead", () => outbound.leads.list());

console.log("requests made:", requests);

// 5. A capability you turn off yourself is refused the same way, which is
//    how to test the unsupported branch of your own code.
const limited = createOutbound({
  adapter: memory({ capabilities: { "stats.campaign": false }, data: seed }),
});

await refused("turned off", () => limited.campaign("cmp_founders").stats());
settings: Unsupported, permanent true
  Reading a campaign's settings is not available: the heyreach adapter does not support it (campaigns.settings)
by step: Unsupported, permanent true
  Reading stats for the step scope is not available: the heyreach adapter does not support it (stats.byStep)
by category: Unsupported, permanent true
  Filtering replies by category is not available: the heyreach adapter does not support it (replies.category)
every lead: Unsupported, permanent true
  Listing leads is not available: the heyreach adapter does not support it (leads.list)
requests made: 0
turned off: Unsupported, permanent true
  Reading stats for the campaign scope is not available: the memory adapter does not support it (stats.campaign)
What happened What you get What to do
A read the vendor does not have Unsupported, naming the capability key, before any request Branch on that key. permanent is true: retrying does not help.
A stat scope the vendor does not report Unsupported, naming the key Skip that part and say so.
A filter the vendor cannot apply Unsupported. The filter is never dropped silently. Read without the filter, or filter in your own code and say the result is yours.
A capability turned off by an option or a plugin Unsupported, naming the adapter or the plugin that turned it off Expected. Use it to test your fallback.
A count the vendor does not report null in the row Print “not reported”. Do not add it up as 0.

Limits

  • Capabilities say what an adapter can read. They do not say the data is the same: a “reply” or an “active” campaign is each vendor’s own notion, mapped to a common name.
  • A client filter is applied to each page after it is read. One page from list can hold fewer items than limit, or none, while its cursor says more exist. listAll follows the cursor for you.
  • On a combined instance, outbound.capabilities is the lowest grade across its adapters, so one weak vendor lowers the whole instance. Scope with outbound.workspace(id) to branch per vendor.
  • A refusal is sometimes thrown by the call and sometimes delivered as a rejected promise. await the call inside try and both arrive in catch.

Was this page helpful?