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

Read every client's outbound data through one instance

One instance over every client key, scoped to a single client when you need it, with per-client totals, separate rate budgets and rules for a workspace that holds several clients.

An agency holds one API key per client, sometimes two when a client is on an email tool and a LinkedIn tool, and sometimes one key whose workspace holds several clients’ campaigns. combine puts any number of adapters behind one instance. The clients plugin tags every record with the client it belongs to and adds totals per client.

Every record keeps its own provider and workspace, so two clients’ data cannot be confused, and nothing is added together across clients unless you ask for it. The combined instance reads each tool live on each call and keeps nothing.

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: one API key per client and tool. Read-only keys are enough. No method of the SDK writes to a vendor.

What you get

Every campaign:
  emailtool:acme:cmp_1  Acme founders
  emailtool:acme:cmp_2  Acme agencies
  linkedintool:acme:cmp_1  Acme on LinkedIn
  emailtool:globex:cmp_1  Globex outreach

acme (combined): 3 campaigns
globex (emailtool): cmp_1 is "Globex outreach"
acme via clients.stats(): 3 campaigns, sent 300, connections sent 40

Workspace stats:
  emailtool:acme  sent 300, connections sent null
  linkedintool:acme  sent null, connections sent 40
  emailtool:globex  sent 50, connections sent null

Per client:
  acme: 3 campaigns, sent 300, replied 10
  globex: 1 campaigns, sent 50, replied 1

One adapter per client key

Give each key its own adapter, and give each adapter a workspaceId. That is your label for the client, not anything the vendor knows. It ends up in every ref: provider:workspace:id.

import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { combine } from "outbound-sdk/combine";
import { memory } from "outbound-sdk/memory";

import { acmeEmail, acmeLinkedin, globexEmail } from "./seed.ts";

// One adapter per client key. `workspaceId` is your own label for the client.
// `name` stands in for the vendor here; a real adapter sets it for you.
export const outbound = createOutbound({
  adapter: combine([
    memory({ data: acmeEmail, name: "emailtool", workspaceId: "acme" }),
    memory({ data: acmeLinkedin, name: "linkedintool", workspaceId: "acme" }),
    memory({ data: globexEmail, name: "emailtool", workspaceId: "globex" }),
  ]),
  // With no rules, one workspace is one client.
  plugins: [clients()],
});
  • A client on two tools carries one label on both adapters. Two adapters may share a label as long as their providers differ.
  • Two adapters with the same provider and the same label are refused. Their records would have the same refs.
  • Plugins are listed once, on the combined instance, and apply to every member.

The samples name the in-memory adapters emailtool and linkedintool so the refs are readable. A real adapter sets its own provider name.

Try it without a key

// Three accounts. "acme" is a client on two tools, email and LinkedIn.
// "globex" is a client on the email tool only. "agency" is one email
// workspace that holds campaigns for several clients, told apart by tag.
import type { MemoryData } from "outbound-sdk/memory";

export const acmeEmail: MemoryData = {
  campaigns: [
    { id: "cmp_1", name: "Acme founders" },
    { id: "cmp_2", name: "Acme agencies" },
  ],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 6, sent: 200 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_2",
      counts: { replied: 4, sent: 100 },
      date: "2026-11-12",
    },
  ],
};

export const acmeLinkedin: MemoryData = {
  campaigns: [{ channel: "linkedin", id: "cmp_1", name: "Acme on LinkedIn" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { connectionsAccepted: 12, connectionsSent: 40 },
      date: "2026-11-12",
    },
  ],
};

export const globexEmail: MemoryData = {
  campaigns: [{ id: "cmp_1", name: "Globex outreach" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 1, sent: 50 },
      date: "2026-11-12",
    },
  ],
};

export const agency: MemoryData = {
  campaigns: [
    { id: "cmp_a", name: "Initech Q4", tags: ["initech"] },
    { id: "cmp_b", name: "Initech webinar", tags: ["initech"] },
    { id: "cmp_c", name: "Umbrella: founders", tags: [] },
    { id: "cmp_d", name: "Internal test", tags: [] },
  ],
  leads: [
    { campaignId: "cmp_a", email: "ana@example.com", id: "lead_1" },
    { campaignId: "cmp_c", email: "ben@example.com", id: "lead_2" },
    { campaignId: "cmp_d", email: "caro@example.com", id: "lead_3" },
  ],
  stats: [
    {
      campaignId: "cmp_a",
      counts: { replied: 5, sent: 100 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_b",
      counts: { replied: 2, sent: 60 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_c",
      counts: { replied: 3, sent: 90 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_d",
      counts: { replied: 0, sent: 4 },
      date: "2026-11-12",
    },
  ],
};
import { outbound } from "./instance.ts";

// Every member's campaigns in one listing. The ref says whose each one is.
console.log("Every campaign:");

for await (const campaign of outbound.campaigns.listAll()) {
  console.log(`  ${campaign.ref}  ${campaign.name}`);
}

// One client. The label is on two adapters, so the scope covers both tools.
const acme = outbound.workspace("acme");
const names = await acme.campaigns.listAll().toArray();

console.log(`\nacme (${acme.provider}): ${names.length} campaigns`);

// A label on one adapter scopes to that adapter, and a bare id works there.
const globex = outbound.workspace("globex");
const campaign = await globex.campaigns.get("cmp_1");

console.log(`globex (${globex.provider}): cmp_1 is "${campaign.name}"`);

// Plugins come along: the scoped instance still has `clients`.
for (const row of await acme.clients.stats()) {
  console.log(
    `acme via clients.stats(): ${row.campaigns} campaigns, sent ${row.counts.sent}, connections sent ${row.counts.connectionsSent}`
  );
}

// Workspace stats: one row per member, side by side. Nothing is added up.
console.log("\nWorkspace stats:");

const workspaceStats = await outbound.stats();

for (const row of workspaceStats.rows) {
  console.log(
    `  ${row.provider}:${row.workspaceId}  sent ${row.sent}, connections sent ${row.connectionsSent}`
  );
}

// The same numbers per client, added up from each client's campaigns.
console.log("\nPer client:");

for (const row of await outbound.clients.stats()) {
  console.log(
    `  ${row.client}: ${row.campaigns} campaigns, sent ${row.counts.sent}, replied ${row.counts.replied}`
  );
}

That is the script that printed the output above. Taking it in order:

One listing covers every member. The first member is read to its end, then the second, and so on. A cursor records which member the walk is in, so it resumes in the right one.

outbound.workspace(id) is one client. It is the same instance scoped to the adapters that carry that label. For acme that is two adapters, so the scope is itself a combined instance and covers both tools. For globex it is one adapter, the scope is that adapter, and a bare id works again.

The scope keeps the plugins. acme.clients.stats() works because clients came along, and the same holds for any plugin’s methods, such as insights. Code written for one client runs unchanged on a scope: the weekly report loops over outbound.workspaces this way.

Workspace stats are one row per member, never added together. outbound.stats() asks each member for its own workspace totals and returns the rows side by side, each with its provider and workspaceId. The LinkedIn tool’s row has sent: null, not 0: it does not count emails.

outbound.clients.stats() adds up, per client. It lists every campaign, reads each campaign’s stats, and sums them by client. For acme that is the two email campaigns and the LinkedIn one. A count that only some campaigns report is the sum of those that do.

A lookup needs a ref

Three members above have a campaign with the id cmp_1. On a combined instance a bare id cannot say which, so every lookup takes a ref: outbound.campaigns.get("emailtool:globex:cmp_1"), and the same for outbound.campaign(ref), outbound.lead(ref), outbound.sender(ref) and a campaignId filter. Every record a listing returns has its ref, so pass that along and never rebuild it.

One workspace that holds several clients

When one key covers several clients, the workspace cannot tell them apart. Pass rules to clients. A rule can match on provider, workspaceId, campaignTag, campaignName (a regular expression) or campaignIds (refs). The first rule that matches decides. A record that matches no rule is unassigned: nothing is guessed.

import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { memory } from "outbound-sdk/memory";

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

// One key, several clients. The first rule that matches decides; a record
// that matches no rule is "unassigned", never guessed into a client.
const outbound = createOutbound({
  adapter: memory({ data: agency, name: "emailtool", workspaceId: "agency" }),
  plugins: [
    clients({
      rules: [
        { campaignTag: "initech", client: "Initech" },
        { campaignName: /^Umbrella:/u, client: "Umbrella" },
      ],
    }),
  ],
});

console.log("clients:", outbound.clients.names());

for (const row of await outbound.clients.stats()) {
  console.log(
    `${row.client}: ${row.campaigns} campaigns, sent ${row.counts.sent}, replied ${row.counts.replied}`
  );
}

// Every record is tagged, not only campaigns. A lead gets its campaign's
// client. The plugin adds `client` at run time; it is not on the Lead type.
for (const lead of await outbound.leads.listAll().toArray()) {
  const client = "client" in lead ? lead.client : undefined;

  console.log(`${lead.email} -> ${String(client)}`);
}
clients: [ 'Initech', 'Umbrella' ]
Initech: 2 campaigns, sent 160, replied 7
Umbrella: 1 campaigns, sent 90, replied 3
unassigned: 1 campaigns, sent 4, replied 0
ana@example.com -> Initech
ben@example.com -> Umbrella
caro@example.com -> unassigned
  • Every record gets a client, not only campaigns. A lead, a message or a reply is assigned through its campaign. A sender belongs to no campaign, so a tag or name rule cannot place it and it is unassigned.
  • The tag is added at run time. It is not on the Lead or Campaign type, so read it with an in check, as the sample does.
  • Check unassigned first. It is where a campaign that someone forgot to tag ends up, and its numbers are missing from every client.
  • Rules and members combine: a rule with workspaceId: "acme" assigns a whole member, and tag rules split another.

Each member keeps its own rate budget

Each adapter’s requests are counted against that adapter’s own credential. A heavy read for one client does not slow another client’s reads. The in-memory adapter makes no HTTP request, so this sample uses two real Instantly adapters with made-up keys and a fetch that answers from Instantly’s documented payloads in the repository’s fixtures/instantly/ folder. Those are documented payloads, not live responses, and nothing leaves the process.

// Two real Instantly adapters with made-up keys and no network: `fetch` is
// replaced with one that answers from Instantly's documented payloads in the
// repository's fixtures/instantly/ folder. Those are not live responses.
import { readFileSync } from "node:fs";

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

const FIXTURES = new URL("../../../../fixtures/instantly/", import.meta.url);

const DOCUMENTED = new Map([
  ["/api/v2/campaigns", "listCampaigns.page2.json"],
  ["/api/v2/custom-tag-mappings", "listCustomTagMappings.json"],
  ["/api/v2/custom-tags", "listCustomTags.page2.json"],
]);

const keysUsed: string[] = [];

const documented: typeof fetch = (input, init) => {
  const request = new Request(input, init);
  const file = DOCUMENTED.get(new URL(request.url).pathname);

  keysUsed.push(request.headers.get("authorization") ?? "none");

  return Promise.resolve(
    file === undefined
      ? new Response("{}", { status: 404 })
      : new Response(readFileSync(new URL(file, FIXTURES), "utf-8"))
  );
};

const outbound = createOutbound({
  adapter: combine([
    instantly({ apiKey: "example-key-acme", workspaceId: "acme" }),
    instantly({ apiKey: "example-key-globex", workspaceId: "globex" }),
  ]),
  fetch: documented,
});

// One listing across both clients, then one more read for acme alone.
await outbound.campaigns.listAll().toArray();

await outbound.workspace("acme").campaigns.list();

for (const snapshot of await outbound.limits()) {
  const [perSecond, perMinute] = snapshot.windows;

  console.log(
    `${snapshot.workspaceId}: used ${perMinute?.used} of ${perMinute?.requests} a minute, ${perSecond?.requests} a second allowed`
  );
}

console.log("keys used:", [...new Set(keysUsed)]);
acme: used 6 of 3000 a minute, 50 a second allowed
globex: used 3 of 3000 a minute, 50 a second allowed
keys used: [ 'Bearer example-key-acme', 'Bearer example-key-globex' ]

outbound.limits() returns one snapshot per member. acme was read twice and globex once, at three requests a read (Instantly’s campaign list also reads tag assignments and tag names). The allowances shown are half of what Instantly publishes, because an instance uses a share of 0.5 of each limit unless you set another.

Budgets are counted in this process. Several processes on the same keys need a shared store: see sharing a rate budget.

Point it at your vendor

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

// Your own list of clients and the variable each key is kept in.
const CLIENTS = [
  {
    heyreach: "ACME_HEYREACH_KEY",
    id: "acme",
    instantly: "ACME_INSTANTLY_KEY",
  },
  { id: "globex", instantly: "GLOBEX_INSTANTLY_KEY" },
];

// Always pass `apiKey`. An adapter given none falls back to its vendor's own
// variable (INSTANTLY_API_KEY), which would be some other client's key.
const key = (name: string): string => {
  const value = process.env[name];

  if (value === undefined || value === "") {
    throw new Error(`${name} is not set`);
  }

  return value;
};

/** Built on first use, so a missing key fails the call and not the import. */
export const createAgency = () =>
  createOutbound({
    adapter: combine(
      CLIENTS.flatMap((client) => [
        instantly({ apiKey: key(client.instantly), workspaceId: client.id }),
        ...(client.heyreach === undefined
          ? []
          : [
              heyreach({
                apiKey: key(client.heyreach),
                workspaceId: client.id,
              }),
            ]),
      ])
    ),
    plugins: [clients()],
  });

Pass apiKey to every adapter. An adapter given none reads its vendor’s own environment variable (INSTANTLY_API_KEY, HEYREACH_API_KEY), so a client whose variable is missing would be read with whatever key that holds. The sample refuses to build the instance instead.

What each vendor can serve

A combined instance promises only what every member can do. This is the in-memory adapter limited to what the Instantly and HeyReach adapters declare, alone and combined. It shows which calls would be refused. It does not show what either API returns.

import { CAPABILITY_KEYS, createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";

import { asVendor } from "../_shared/as-vendor.ts";
import { acmeEmail, acmeLinkedin } from "./seed.ts";

const email = createOutbound({
  adapter: asVendor("instantly", acmeEmail, "acme"),
});

const linkedin = createOutbound({
  adapter: asVendor("heyreach", acmeLinkedin, "acme"),
});

const both = createOutbound({
  adapter: combine([
    asVendor("instantly", acmeEmail, "acme"),
    asVendor("heyreach", acmeLinkedin, "acme"),
  ]),
});

// A combined instance promises only what every member can do.
for (const key of CAPABILITY_KEYS) {
  if (both.capabilities[key] === false) {
    const had = [email, linkedin].flatMap((member) =>
      member.capabilities[key] === false ? [] : [member.provider]
    );

    if (had.length > 0) {
      console.log(`${key}: off when combined, on for ${had.join(" and ")}`);
    }
  }
}
campaigns.settings: off when combined, on for instantly
leads.engagement: off when combined, on for instantly
leads.list: off when combined, on for instantly
messages.byThread: off when combined, on for instantly and heyreach
native: off when combined, on for instantly and heyreach
replies.category: off when combined, on for instantly
senders.warmup: off when combined, on for instantly
sequence.subsequences: off when combined, on for instantly
stats.byStep: off when combined, on for instantly
tags: off when combined, on for instantly

Combining an email tool with HeyReach turns off what HeyReach lacks, for every member: reply categories, per-step stats, the list of every lead. Two capabilities are off on every combined instance whatever the members: messages.byThread, because a thread id is the vendor’s own and names no member, and native, because a native read belongs to one vendor.

To get a capability back, scope to one member. A label that is on one adapter does that. For a client on two tools, outbound.workspace("acme") still covers both and is still limited to what both can do; name the member as provider:label, for example outbound.workspace("emailtool:acme").

What it costs in vendor requests

  • A listing across members costs each member’s listing, on that member’s budget.
  • outbound.stats() at workspace scope is one stats read per member.
  • outbound.clients.stats() is one campaign listing per member plus one stats read per campaign. Pass from and to to bound the dates, and maxRequests to cap the call.
  • A tag or name rule needs the campaign of each record. It is fetched once per campaign and remembered for the life of the instance.
  • outbound.workspaces.list() on a combined instance makes no request: the workspaces are its members.

When it cannot answer

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

import { outbound } from "./instance.ts";
import { acmeEmail } from "./seed.ts";

const attempt = async (
  label: string,
  read: () => object | Promise<object>
): Promise<void> => {
  try {
    await read();
    console.log(`${label}: ok`);
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

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

// 1. Three members have a campaign "cmp_1". A bare id cannot say which.
await attempt("bare id", () => outbound.campaigns.get("cmp_1"));

await attempt("ref", () => outbound.campaigns.get("emailtool:globex:cmp_1"));

// 2. A label no adapter carries.
await attempt("unknown client", () => outbound.workspace("initech"));

// 3. Two adapters that would produce the same refs.
await attempt("same label twice", () =>
  combine([memory({ data: acmeEmail }), memory({ data: acmeEmail })])
);

// 4. A read by the vendor's own thread id names no member, so it is off on
//    every combined instance. Scope to one member to use it.
await attempt("thread on combined", () =>
  outbound.thread("thread_1").messages()
);

await attempt("thread on one workspace", () =>
  outbound.workspace("globex").thread("thread_1").messages()
);

// acme is on two tools. `provider:label` names one of them.
await attempt("thread on one member", () =>
  outbound.workspace("emailtool:acme").thread("thread_1").messages()
);

// 5. A failing member fails the combined read. Nothing is returned for the
//    members that answered.
const base = memory({ data: acmeEmail, name: "emailtool", workspaceId: "b" });

const oneDown = createOutbound({
  adapter: combine([
    memory({ data: acmeEmail, name: "emailtool", workspaceId: "a" }),
    {
      ...base,
      stats: () =>
        Promise.reject(
          new OutboundError("Unauthorized", "emailtool responded 401", {
            provider: "emailtool",
            status: 401,
          })
        ),
    },
  ]),
});

await attempt("one key rejected", () => oneDown.stats());

await attempt("the other client alone", () => oneDown.workspace("a").stats());
bare id: Invalid - Campaign "cmp_1" must be a ref (provider:workspace:id) of an adapter on this combined instance
ref: ok
unknown client: NotFound - Workspace initech is not on this instance
same label twice: Invalid - Two adapters are both memory:default. Give each one its own workspaceId so their records stay apart
thread on combined: Unsupported - Reading one conversation is not available: the combined adapter does not support it (messages.byThread)
thread on one workspace: ok
thread on one member: ok
one key rejected: Unauthorized - emailtool responded 401
the other client alone: ok
What happened What you get What to do
A bare id on a combined instance Invalid, asking for a ref Pass the record’s ref, or scope to the workspace first.
A label no adapter carries NotFound, from workspace(id) itself Check the label against outbound.workspaces.
Two adapters with the same provider and label Invalid, when combine is called Give each its own workspaceId.
A capability one member lacks Unsupported, before any request Scope to a member that has it.
One member’s key is rejected The whole combined read fails with that member’s error. No partial answer is returned. Read per workspace and catch per client, so one bad key costs one client.

Limits

  • A combined instance cannot be nested inside another.
  • Rows from different members are never merged. The same person in two tools is two leads; the identity plugin links them without merging.
  • clients.stats() reads every campaign’s stats one at a time. On many clients it is the most expensive call on this page.
  • One failing member fails a combined read. There is no partial result.
  • The numbers are only as good as each vendor’s. The combined instance’s maturity is that of its least verified member.

Was this page helpful?