---
title: Combine adapters
description: 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.
sidebar:
  label: Combine
related:
  - /docs/adapters
  - /docs/plugins/clients
  - /guides/run-many-clients
  - /guides/person-across-email-and-linkedin
---

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

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

```text title="Output"
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](/docs/status).
- **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.

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

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

```text title="Output"
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](#scope-to-one-client)).

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.

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

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

> **native and raw on a shared label**
>
> The error for `native` and `raw` on a combined instance says to use
> `outbound.workspace(id)`. That works only when one member carries the label.
> For a label on two tools, as `acme` is above, there is no way to reach one
> member's native catalog or raw API from the combined instance. Keep a separate
> `createOutbound({adapter})` for that member.

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

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

```text title="Output"
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](/docs/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](/docs/insights) and the [identity plugin](/docs/plugins/identity), which work on a combined instance.
