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.
openedis “not reported” on all three. The seed has no open count, so the count isnull. 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:
capabilitiesis data on the instance. - Carry
meta.evidenceto the reader.exactis the vendor’s number.derivedwas computed or filtered by the SDK.sampledis built on part of the data. - Print
nullas “not reported”. Anullcount means the vendor did not say. A rate built on it isnulltoo, whichcomputeRateshandles.
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.
requestsisnullwhen 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.estimatedoes not readcapabilities. HeyReach gets a number forleads.listalthough 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
clientfilter is applied to each page after it is read. One page fromlistcan hold fewer items thanlimit, or none, while its cursor says more exist.listAllfollows the cursor for you. - On a combined instance,
outbound.capabilitiesis the lowest grade across its adapters, so one weak vendor lowers the whole instance. Scope withoutbound.workspace(id)to branch per vendor. - A refusal is sometimes thrown by the call and sometimes delivered as a rejected promise.
awaitthe call insidetryand both arrive incatch.