---
title: Adapters
description: What an adapter is, the four that ship, what each can and cannot do in one table checked against the code, and how much of each vendor's API is covered.
sidebar:
  label: Overview
related:
  - /docs/capabilities
  - /docs/status
  - /docs/coverage
---

An adapter is the part that knows one vendor. It describes each request and maps the response to the SDK's records. It never retries, sleeps or walks pages: `createOutbound` does that around it, the same way for every vendor. You pass one adapter to `createOutbound` and call the same methods whichever vendor is behind it (see [usage](/docs/usage)).

Each vendor adapter has two layers: normalized reads that mean the same thing on every vendor, and a typed [native catalog](/docs/native) of the vendor's own reads for everything else.

## The adapters

| Adapter | Import | Channel | Key variable | Page |
| --- | --- | --- | --- | --- |
| Instantly (API V2) | `outbound-sdk/instantly` | email | `INSTANTLY_API_KEY` | [Instantly](/docs/adapters/instantly) |
| Smartlead | `outbound-sdk/smartlead` | email | `SMARTLEAD_API_KEY` | [Smartlead](/docs/adapters/smartlead) |
| HeyReach | `outbound-sdk/heyreach` | LinkedIn | `HEYREACH_API_KEY` | [HeyReach](/docs/adapters/heyreach) |
| In-memory | `outbound-sdk/memory` | both | none | [In-memory](/docs/adapters/memory) |

[`combine`](/docs/adapters/combine) from `outbound-sdk/combine` puts several of them, or several keys for one of them, behind one instance.

Every vendor factory takes the same core options:

| Option | Default | What it is |
| --- | --- | --- |
| `apiKey` | the key variable above | The vendor key. A missing key throws when the adapter is constructed. |
| `workspaceId` | `<PROVIDER>_WORKSPACE_ID`, then `default` | Your own label for the client or workspace. It is part of every ref (`provider:workspace:id`). |
| `baseUrl` | the vendor's API | For a proxy or a test server. |
| `capabilities` | what the adapter declares | Overrides single capabilities, for example to turn one off on a plan without the endpoint. |

Constructing an adapter makes no request.

## How far each is verified

One line each, from [what is verified](/docs/status), which is the page to read before relying on a number:

- **Instantly** is `beta`: its reads were run, read-only, against one live workspace on 2026-11-14.
- **HeyReach** is `beta`: its reads were run, read-only, against one live workspace on 2026-11-14.
- **Smartlead** is `unverified`: written from Smartlead's public documentation and tested offline only.
- **In-memory** is `stable`: it has no vendor behind it.

`outbound.maturity` reports this at run time, and `outbound.caveats` lists what the adapter assumes and has not confirmed. Each adapter's page shows its caveats in full.

## What each adapter can do

A filter is `server` (the vendor filters), `client` (the SDK filters after fetching and marks the result `derived`) or `no`. A stat is `exact`, `derived` or `no`. A call that needs a `no` is refused as `Unsupported` before any request leaves. The repository's test suite checks this table against the adapters' own declarations (`packages/outbound-sdk/test/adapters-doc.test.ts`). See [capabilities](/docs/capabilities) for what each key gates and how to branch on it.

| Capability              | Instantly | Smartlead | HeyReach |
| ----------------------- | --------- | --------- | -------- |
| `blocklist`             | yes       | yes       | yes      |
| `campaigns.settings`    | yes       | yes       | no       |
| `campaigns.status`      | server    | client    | server   |
| `deletionDetection`     | no        | no        | no       |
| `events.parse`          | yes       | yes       | yes      |
| `events.verify`         | secret    | signature | secret   |
| `leads.byCampaign`      | yes       | yes       | yes      |
| `leads.engagement`      | yes       | no        | no       |
| `leads.get`             | yes       | yes       | yes      |
| `leads.list`            | yes       | yes       | no       |
| `leads.lookup`          | yes       | yes       | yes      |
| `leads.since`           | client    | client    | server   |
| `leads.timeline`        | yes       | yes       | yes      |
| `lists.leads`           | yes       | no        | yes      |
| `lists.list`            | yes       | yes       | yes      |
| `messages.body`         | full      | full      | full     |
| `messages.byThread`     | yes       | yes       | yes      |
| `messages.list`         | yes       | yes       | yes      |
| `messages.since`        | server    | client    | server   |
| `native`                | yes       | yes       | yes      |
| `replies.category`      | vendor    | vendor    | no       |
| `replies.list`          | yes       | yes       | yes      |
| `replies.since`         | server    | client    | server   |
| `senders.byCampaign`    | yes       | yes       | yes      |
| `senders.get`           | yes       | yes       | yes      |
| `senders.health`        | yes       | yes       | yes      |
| `senders.list`          | yes       | yes       | yes      |
| `senders.warmup`        | yes       | yes       | no       |
| `sequence`              | yes       | yes       | yes      |
| `sequence.subsequences` | yes       | yes       | no       |
| `stats.byDay`           | exact     | exact     | exact    |
| `stats.bySender`        | derived   | exact     | exact    |
| `stats.byStep`          | exact     | exact     | no       |
| `stats.campaign`        | exact     | exact     | exact    |
| `stats.workspace`       | exact     | exact     | exact    |
| `tags`                  | yes       | yes       | no       |
| `webhooks.list`         | yes       | yes       | yes      |
| `workspaces`            | yes       | yes       | no       |

The in-memory adapter declares everything except `native`, `events.parse`, `events.verify` and `deletionDetection`.

## What stays `no`, and why

What a vendor's API cannot answer stays `no`. The SDK does not fill the gap with a guess.

- **HeyReach has no campaign settings, subsequences, tags or per-lead counts.** Its API can set a campaign's schedule but not read it, a campaign has one sequence tree, no endpoint lists the workspace's tags, and no lead carries open or reply counts.
- **HeyReach has no per-step stats** and no warmup: LinkedIn accounts are not warmed up.
- **HeyReach replies carry no category.** Its auto-tags describe the lead, not a message; the newest one is the lead's `interest`.
- **HeyReach has no list of every lead.** Leads are read per campaign or per saved list, or found by profile or email.
- **HeyReach cannot list workspaces** with a workspace key.
- **Smartlead cannot list the leads of a saved list**, and its per-lead counts are documented only as a placeholder response.
- **Instantly's per-mailbox stats are `derived`:** its mailbox analytics exist per day only, so the adapter sums them.
- **No adapter reveals deleted records.** The [reconcile plugin](/docs/plugins/reconcile) compares what you stored with what the vendor still lists.

## How much of each vendor's API is covered

Every endpoint in each vendor's API reference is listed on its [coverage](/docs/coverage) page with how the SDK reaches it: through a normalized read, through the native catalog, excluded with a reason, or a write the SDK does not make.

| Vendor | Endpoints | Normalized | Native | Excluded | Writes |
| --- | --- | --- | --- | --- | --- |
| [Instantly](/docs/coverage/instantly) | 268 | 22 | 105 | 4 | 137 |
| [Smartlead](/docs/coverage/smartlead) | 230 | 27 | 78 | 20 | 105 |
| [HeyReach](/docs/coverage/heyreach) | 106 | 17 | 25 | 3 | 61 |

A read is excluded only when it returns secrets (API keys, mailbox passwords, proxy credentials), duplicates another endpoint's data exactly, reports nothing about campaigns, leads, senders or results (billing, marketplace pricing, search filter lists), or returns a file the SDK cannot carry. Reads that spend vendor credits are listed as writes.

## Rate limits each adapter declares

| Adapter | Declared limit | Stricter lanes |
| --- | --- | --- |
| Instantly | 100 per second and 6,000 per minute, per workspace | `GET /emails`: 20 per minute |
| Smartlead | 60 per minute (the limit of a client key; an admin key is documented at 200) | three endpoints at 10 per minute |
| HeyReach | 15 per 2 seconds (seen live, not documented) and 300 per minute | the organization API: 300 per minute |

The SDK spends `share` (default 0.5) of these numbers, because the vendor's limit is shared with the customer's other tools. [Rate limits](/docs/rate-limits) explains the budget, and [what is verified](/docs/status) says which of these numbers were measured.

## The provider catalog and choosing an adapter at run time

`outbound-sdk/providers` lists every provider with its channels, its maturity and the environment variables it reads, without importing any adapter. `outbound-sdk/loader` builds the adapter that `OUTBOUND_SDK_PROVIDER` names, reading that adapter's own key from the environment; the [CLI](/docs/cli) picks its adapter this way.

```ts title="catalog.ts"
import { createOutbound } from "outbound-sdk";
import { fromEnv, loadableProviders } from "outbound-sdk/loader";
import { providers } from "outbound-sdk/providers";

// The catalog imports no adapter, so it is safe in a config screen.
for (const provider of providers) {
  const required = provider.envVars
    .filter((variable) => variable.required)
    .map((variable) => variable.name);

  console.log(provider.id, provider.maturity, provider.channels, required);
}

// fromEnv() builds the adapter OUTBOUND_SDK_PROVIDER names, or the one you
// pass. Only that adapter's code is loaded.
console.log(loadableProviders());

const outbound = createOutbound({
  adapter: await fromEnv({ provider: "memory" }),
});

const campaigns = await outbound.campaigns.list();

console.log(outbound.provider, campaigns.items.length);
```

```text title="Output"
instantly beta [ 'email' ] [ 'INSTANTLY_API_KEY' ]
smartlead unverified [ 'email' ] [ 'SMARTLEAD_API_KEY' ]
heyreach beta [ 'linkedin' ] [ 'HEYREACH_API_KEY' ]
memory stable [ 'email', 'linkedin' ] []
[ 'heyreach', 'instantly', 'memory', 'smartlead' ]
memory 2
```

## Writing an adapter

The adapter contract is the `Adapter` type exported from `outbound-sdk`. The checklist for adding one is in the repository, in `docs/new-adapter.md`, with the research note each existing adapter was written from under `docs/research/`.
