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

Combine adapters

Put several adapters or several keys behind one instance, and know what the combined instance promises, how lookups are routed, and what it cannot do.

combine([...]) from outbound-sdk/combine takes several adapters and returns one. Use it for a client on two tools (email and LinkedIn), for an agency with one key per client, or both. Every record keeps its own provider, workspaceId and ref, so data from two tools or two clients cannot be confused.

A combined instance reads every member live and keeps nothing. Each call pays each vendor’s rate limit, on that vendor’s own budget.

Construct it

Give each adapter a workspaceId: your own label for the client the key belongs to. Two members may share a label when they are different vendors. Two members with the same vendor and the same label are refused with Invalid, as are an empty list and a combined adapter inside another.

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

// One adapter per key. Each `workspaceId` is your own label for the client.
// The keys are read from your own environment variables or secret store.
const outbound = createOutbound({
  adapter: combine([
    instantly({ apiKey: process.env.ACME_INSTANTLY_KEY, workspaceId: "acme" }),
    heyreach({ apiKey: process.env.ACME_HEYREACH_KEY, workspaceId: "acme" }),
    smartlead({
      apiKey: process.env.GLOBEX_SMARTLEAD_KEY,
      workspaceId: "globex",
    }),
  ]),
});

// Nothing below makes a request.
console.log("maturity:", outbound.maturity);

console.log("caveats:", outbound.caveats.length);

console.log(outbound.caveats[0]?.slice(0, 60));

// One rate budget per member, each from its own vendor's declared limits.
for (const snapshot of await outbound.limits()) {
  const [first] = snapshot.windows;

  console.log(
    snapshot.provider,
    snapshot.workspaceId,
    `${first?.requests} per ${(first?.perMs ?? 0) / 1000}s`
  );
}
maturity: unverified
caveats: 101
instantly (acme): The reads were run once, read-only, agains
instantly acme 50 per 1s
heyreach acme 7 per 2s
smartlead globex 30 per 60s

That sample constructs real adapters and makes no request. It shows three things:

  • Maturity is the weakest member’s. One unverified member makes the instance unverified. See what is verified.
  • Caveats are every member’s, each prefixed with the member’s provider and label.
  • Each member has its own rate budget, from its own vendor’s declared limits and its own key. outbound.limits() returns one snapshot per member.

The rest of this page runs on in-memory members limited to what Instantly and HeyReach declare, so the refusals are the ones the real adapters would make. The records are seed data, not vendor responses.

import type { MemoryData } from "outbound-sdk/memory";

// Client "acme" runs email and LinkedIn. Client "globex" runs email only.
export const acmeEmail: MemoryData = {
  campaigns: [
    { id: "cmp_1", name: "Acme founders" },
    { id: "cmp_2", name: "Acme agencies", status: "paused" },
  ],
  leads: [{ campaignId: "cmp_1", email: "person1@example.com", id: "lead_1" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 6, sent: 200 },
      date: "2026-11-10",
    },
  ],
};

export const acmeLinkedin: MemoryData = {
  campaigns: [{ channel: "linkedin", id: "cmp_1", name: "Acme LinkedIn" }],
  leads: [
    {
      campaignId: "cmp_1",
      id: "lead_1",
      linkedinUrl: "https://www.linkedin.com/in/example-person-1",
    },
  ],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { connectionsAccepted: 9, connectionsSent: 40 },
      date: "2026-11-10",
    },
  ],
};

export const globexEmail: MemoryData = {
  campaigns: [{ id: "cmp_1", name: "Globex CFOs" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 2, sent: 90 },
      date: "2026-11-10",
    },
  ],
};

What it promises

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

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

// Three members: in-memory adapters limited to what Instantly and HeyReach
// declare. In production these are instantly({ ... }) and heyreach({ ... }).
const members = [
  asVendor("instantly", acmeEmail, "acme"),
  asVendor("heyreach", acmeLinkedin, "acme"),
  asVendor("instantly", globexEmail, "globex"),
];

const outbound = createOutbound({ adapter: combine(members) });

console.log(outbound.provider, outbound.workspaceId);

// What one member can do and the combined instance cannot promise.
const [instantlyOnly] = members.map((member) =>
  createOutbound({ adapter: member })
);

console.log(
  "given up:",
  CAPABILITY_KEYS.filter(
    (key) =>
      instantlyOnly?.capabilities[key] !== false &&
      outbound.capabilities[key] === false
  ).join(", ")
);

// One listing walks every member in turn. Each record keeps its own ref.
for await (const campaign of outbound.campaigns.listAll()) {
  console.log(campaign.ref, "-", campaign.name);
}

// A lookup needs the ref: three members each have a campaign "cmp_1".
const found = await outbound.campaigns.get("heyreach:acme:cmp_1");

console.log("by ref:", found.name);

try {
  await outbound.campaigns.get("cmp_1");
} catch (error) {
  if (!OutboundError.is(error)) {
    throw error;
  }

  console.log("bare id:", error.code, "-", error.message);
}

// Workspace stats: one row per member, never added together.
const stats = await outbound.stats();

for (const row of stats.rows) {
  console.log(
    row.scopeRef,
    "sent",
    row.sent,
    "connections",
    row.connectionsSent
  );
}
combined combined
given up: campaigns.settings, leads.engagement, leads.list, messages.byThread, native, replies.category, senders.warmup, sequence.subsequences, stats.byStep, tags
instantly:acme:cmp_1 - Acme founders
instantly:acme:cmp_2 - Acme agencies
heyreach:acme:cmp_1 - Acme LinkedIn
instantly:globex:cmp_1 - Globex CFOs
by ref: Acme LinkedIn
bare id: Invalid - Campaign "cmp_1" must be a ref (provider:workspace:id) of an adapter on this combined instance
instantly:acme:acme sent 200 connections null
heyreach:acme:acme sent null connections 40
instantly:globex:globex sent 90 connections null

Only what every member can do

The combined instance’s capabilities are the weakest value of each key across its members. Put HeyReach beside Instantly and the instance gives up everything HeyReach cannot do: the given up line above. A call that needs one of those is refused as Unsupported on the combined instance, even for a record that lives in the member that could serve it. Scope to that member to get it back (see below).

Three keys do not follow the weakest member:

Capability On a combined instance Why
workspaces always yes Its workspaces are its members. Listing them makes no request.
native always no A native read belongs to one vendor.
messages.byThread always no A thread id is the vendor’s own and names no member, so outbound.thread(id) cannot be routed.

Listings walk every member

A listing reads the first member to its end, then the second, and so on, in the order you passed them. The cursor records which member the walk is in, so a saved cursor resumes in the right place. Records are not merged or sorted across members.

A listing filtered to one campaign or one saved list goes straight to the member that owns it.

Lookups need a ref

Three members above each have a campaign with the id cmp_1. A lookup on a combined instance therefore takes the record’s ref (provider:workspace:id), which every record carries. A bare vendor id is refused with Invalid. This applies to campaigns.get, to campaign(id), sender(id), lead(id) and list(id), and to a campaignId filter.

Stats stay one row per member

outbound.stats() on a combined instance returns one row per member, side by side. They are never added together, because they are different workspaces and often different channels. A count a member does not report is null in its row.

Scope to one client

outbound.workspace(id) returns the same API scoped to one label. It keeps the plugins of the instance it came from and the methods they add, so outbound.workspace(id).insights works.

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

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

const outbound = createOutbound({
  adapter: combine([
    asVendor("instantly", acmeEmail, "acme"),
    asVendor("heyreach", acmeLinkedin, "acme"),
    asVendor("instantly", globexEmail, "globex"),
  ]),
  plugins: [insights()],
});

// The workspaces of a combined instance are its members. No request is made.
const workspaces = await outbound.workspaces.list();

console.log(workspaces.items.map((workspace) => workspace.ref));

// One member carries "globex", so the scope is that member: a bare id works.
const globex = outbound.workspace("globex");
const campaign = await globex.campaigns.get("cmp_1");

console.log(globex.provider, globex.workspaceId, "-", campaign.name);

// Two members carry "acme", so the scope is those two, still combined.
const acme = outbound.workspace("acme");
const campaigns = await acme.campaigns.listAll().toArray();

console.log(acme.provider, "-", campaigns.map((item) => item.ref).join(", "));

// The scope keeps the plugins of the instance it came from.
const ranking = await globex.insights.campaignRanking({});

console.log("insight ran on:", ranking.meta.provider);

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

    console.log(label, "-", error.code, "-", error.message);
  }
};

await attempt("combined native", () => outbound.native.read("workspace"));

await attempt("combined raw", () => outbound.raw.get("/campaigns"));

await attempt("combined thread", () => outbound.thread("thr_1").messages());

await attempt("acme native", () => acme.native.read("workspace"));

await attempt("unknown label", () => outbound.workspace("initech"));
[
  'instantly:acme:acme',
  'heyreach:acme:acme',
  'instantly:globex:globex'
]
instantly globex - Globex CFOs
combined - instantly:acme:cmp_1, instantly:acme:cmp_2, heyreach:acme:cmp_1
insight ran on: instantly
combined native - Unsupported - native is not available on a combined instance: it belongs to one vendor. Use outbound.workspace("provider:workspace").native, for example "instantly:acme" or "heyreach:acme" or "instantly:globex"
combined raw - Unsupported - raw is not available on a combined instance: it belongs to one vendor. Use outbound.workspace("provider:workspace").raw, for example "instantly:acme" or "heyreach:acme" or "instantly:globex"
combined thread - Unsupported - Reading one conversation is not available: the combined adapter does not support it (messages.byThread)
acme native - Unsupported - native is not available on a combined instance: it belongs to one vendor. Use outbound.workspace("provider:workspace").native, for example "instantly:acme" or "heyreach:acme"
unknown label - NotFound - Workspace initech is not on this instance
  • A label one member carries scopes to that member. The result behaves like an instance built on that adapter alone: its own capabilities, bare ids, native and raw.
  • A label several members carry scopes to all of them. A client on two tools keeps one label on both adapters, and its scope is those members, still combined. Lookups there still need refs, and native, raw and thread are still unavailable.
  • An unknown label throws NotFound.

Webhooks

The adapter that combine returns can route a webhook payload: the member whose format matches parses it, and the events carry that member’s workspace label. The combined adapter has no signature check of its own.

import { readFileSync } from "node:fs";

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

const adapter = combine([
  instantly({ workspaceId: "acme" }),
  heyreach({ workspaceId: "globex" }),
]);

const outbound = createOutbound({ adapter });

console.log("events.parse:", outbound.capabilities["events.parse"]);

console.log("events.verify:", outbound.capabilities["events.verify"]);

// Instantly's documented reply_received payload, from the repository's
// fixtures/instantly/ folder. It is not a delivery from Instantly.
const rawBody = readFileSync(
  new URL(
    "../../../../fixtures/instantly/webhook.reply_received.json",
    import.meta.url
  ),
  "utf-8"
);

const request: WebhookRequest = {
  body: JSON.parse(rawBody),
  headers: new Headers(),
  rawBody,
  url: "https://hooks.example.com/outbound",
};

// The member whose format matches parses it, under that member's label.
// The workspace argument is ignored on a combined adapter.
const events = adapter.events?.parse(request, "ignored") ?? [];

for (const event of events) {
  console.log(event.type, event.provider, event.workspaceId);
}

// The combined adapter has no signature check of its own.
console.log("verify:", adapter.events?.verify);
events.parse: true
events.verify: secret
reply.received instantly acme
verify: undefined

This is the adapter contract, one level below the usual entry point. parse from outbound-sdk/webhooks does not take an adapter: it detects the vendor from the payload, verifies with the secret you pass and labels events with the workspaceId you pass. With one endpoint per client that is enough, and it is the documented path. See webhooks.

Two members of the same vendor cannot be told apart by payload format: the first one that matches parses the payload. Give each client its own webhook URL and call parse with that client’s workspaceId.

What it cannot do

Call On a combined instance What to do
outbound.native, outbound.raw Unsupported outbound.workspace(id) when one member carries the label; otherwise a separate instance.
outbound.thread(id) Unsupported Scope to the member, or read the lead’s timeline() by ref.
A lookup by bare id Invalid Pass the record’s ref.
A read one member lacks Unsupported Scope to a member that has it.
outbound.estimate(...) requests: null The combined adapter declares no costs. Estimate on a scoped instance.
One sorted list across members not offered Collect with listAll() and sort.

To answer questions across members, for example one person across email and LinkedIn, use the insights and the identity plugin, which work on a combined instance.

Was this page helpful?