---
title: Write one function that runs on Instantly, Smartlead and HeyReach
description: 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.
sidebar:
  label: Code that runs on any vendor
related:
  - /docs/capabilities
  - /docs/api/results
  - /docs/api/errors
  - /docs/adapters
---

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

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

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

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

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

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

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

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

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

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

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

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