---
title: Capabilities
description: Every adapter declares what it can read as a flat map. What each key gates, what the grades mean, how to branch on the map, and how an adapter option or a plugin changes it.
sidebar:
  label: Capabilities
related:
  - /docs/adapters
  - /docs/api/errors
  - /guides/portable-code
---

The vendors do not offer the same reads. One has no list of every lead, another cannot report stats per step. Each adapter declares what it can do as a flat map of keys, and `outbound.capabilities` is that map. Reading it sends nothing.

Two rules follow from the map:

- **A read the adapter cannot serve is refused before any request.** It throws `Unsupported`, naming the key. You never get an empty list that means "not supported".
- **Anything an adapter does not declare is `false`.** An adapter that says nothing never advertises a wrong yes.

```ts title="read.ts"
import { CAPABILITY_KEYS, createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { instantly } from "outbound-sdk/instantly";
import { smartlead } from "outbound-sdk/smartlead";

// Constructing an instance sends nothing, so neither does this.
const vendors = [
  createOutbound({ adapter: instantly() }),
  createOutbound({ adapter: smartlead() }),
  createOutbound({ adapter: heyreach() }),
];

console.log(CAPABILITY_KEYS.length, "keys");

const keys = [
  "campaigns.settings",
  "leads.list",
  "leads.since",
  "messages.body",
  "stats.byStep",
  "stats.bySender",
  "events.verify",
] as const;

for (const key of keys) {
  console.log(
    key.padEnd(20),
    vendors
      .map((outbound) => `${outbound.provider} ${outbound.capabilities[key]}`)
      .join(" | ")
  );
}
```

```text title="Output"
38 keys
campaigns.settings   instantly true | smartlead true | heyreach false
leads.list           instantly true | smartlead true | heyreach false
leads.since          instantly client | smartlead client | heyreach server
messages.body        instantly full | smartlead full | heyreach full
stats.byStep         instantly exact | smartlead exact | heyreach false
stats.bySender       instantly derived | smartlead exact | heyreach exact
events.verify        instantly secret | smartlead signature | heyreach secret
```

That prints what each adapter declares for seven of the keys. The full table, per vendor, is on the [adapters](/docs/adapters) page.

## Branch on it

Code that has to run on more than one vendor asks the map first, or makes the call and handles `Unsupported`.

```ts title="branch.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import type { OutboundApi } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { demoData, memory } from "outbound-sdk/memory";

import { fakeVendor } from "../retries/fake-vendor.ts";

// Code that works on any adapter asks first.
const dailyLimit = async (
  outbound: OutboundApi,
  campaignId: string
): Promise<number | null> => {
  if (!outbound.capabilities["campaigns.settings"]) {
    return null;
  }

  const settings = await outbound.campaign(campaignId).settings();

  return settings.dailyLimit;
};

// The in-memory adapter with one capability turned off.
const limited = createOutbound({
  adapter: memory({
    capabilities: { "campaigns.settings": false },
    data: demoData(),
  }),
});

const full = createOutbound({ adapter: memory({ data: demoData() }) });

console.log("full:", await dailyLimit(full, "cmp_founders"));

console.log("limited:", await dailyLimit(limited, "cmp_founders"));

// Or call it and handle the refusal.
try {
  await limited.campaign("cmp_founders").settings();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}

// HeyReach's API cannot read campaign settings. The refusal comes from the
// SDK, so the stand-in vendor behind this instance is never reached.
const vendor = fakeVendor(200);
const linkedin = createOutbound({ adapter: heyreach(), fetch: vendor.fetch });

try {
  await linkedin.campaign("123").settings();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}

console.log("requests sent:", vendor.count());
```

```text title="Output"
full: 80
limited: null
Unsupported - Reading a campaign's settings is not available: the memory adapter does not support it (campaigns.settings)
Unsupported - Reading a campaign's settings is not available: the heyreach adapter does not support it (campaigns.settings)
requests sent: 0
```

The last instance is a real HeyReach adapter in front of a stand-in vendor that counts requests. The refusal comes from the SDK, and the count stays at 0.

A value is `false` or something truthy, so `if (outbound.capabilities["leads.list"])` works for every key. Compare against a grade when the grade matters to you.

## Every key

`CAPABILITY_KEYS` is the list of all 38. `Capabilities` is the type of the map, and `CapabilityKey` the type of a key.

### Campaigns and sequences

| Key | Values | Gates |
| --- | --- | --- |
| `campaigns.status` | `server`, `client`, `false` | The `status` filter on `campaigns.list` |
| `campaigns.settings` | boolean | `campaign(id).settings()` |
| `sequence` | boolean | `campaign(id).sequence()` |
| `sequence.subsequences` | boolean | `campaign(id).subsequences` |

Listing campaigns and reading one need no key. Every adapter can.

### Senders

| Key | Values | Gates |
| --- | --- | --- |
| `senders.list` | boolean | `senders.list` with no `campaignId` |
| `senders.byCampaign` | boolean | `senders.list({ campaignId })` and `campaign(id).senders`. Does not need `senders.list`. |
| `senders.get` | boolean | `senders.get`, `sender(id).get()`, and `sender(id).warmup()` |
| `senders.warmup` | boolean | `sender(id).warmup()` |
| `senders.health` | boolean | `sender(id).health()` |

### Leads and lists

| Key | Values | Gates |
| --- | --- | --- |
| `leads.list` | boolean | `leads.list` across every campaign, with no `campaignId`, `listId` or lookup |
| `leads.byCampaign` | boolean | `leads.list({ campaignId })` and `campaign(id).leads`. Does not need `leads.list`. |
| `leads.get` | boolean | `leads.get` and `lead(id).get()` |
| `leads.lookup` | boolean | The `email` and `linkedinUrl` filters: finding a lead without walking every lead |
| `leads.since` | `server`, `client`, `false` | The `since` and `until` filters on leads |
| `leads.timeline` | boolean | `lead(id).timeline()` |
| `leads.engagement` | boolean | No read on the instance. It says whether leads carry `engagement`: open, click and reply counts. Some [insights](/docs/insights) check it. |
| `lists.list` | boolean | `lists.list` |
| `lists.leads` | boolean | `leads.list({ listId })` and `list(id).leads` |

### Messages and replies

| Key | Values | Gates |
| --- | --- | --- |
| `messages.list` | boolean | `messages.list`, `campaign(id).messages`, `lead(id).timeline()`, `thread(id).messages()` |
| `messages.byThread` | boolean | The `threadId` filter and `thread(id).messages()` |
| `messages.since` | `server`, `client`, `false` | The `since` and `until` filters on messages |
| `messages.body` | `full`, `sampled`, `false` | `include: ["body"]` on messages and on replies |
| `replies.list` | boolean | `replies.list` and `campaign(id).replies` |
| `replies.category` | `vendor`, `false` | The `category` filter on replies |
| `replies.since` | `server`, `client`, `false` | The `since` and `until` filters on replies |

`messages.body` is `full` when the vendor returns every body and `sampled` when it returns some. `replies.category` is `vendor` when the categories come from the vendor. The SDK never asks a model to classify a reply.

### Stats

| Key | Values | Gates |
| --- | --- | --- |
| `stats.workspace` | `exact`, `derived`, `false` | `outbound.stats()` |
| `stats.campaign` | `exact`, `derived`, `false` | `campaign(id).stats()` |
| `stats.byStep` | `exact`, `derived`, `false` | `campaign(id).stats({ scope: "step" })` |
| `stats.bySender` | `exact`, `derived`, `false` | `sender(id).stats()` |
| `stats.byDay` | `exact`, `derived`, `false` | `by: "day"` on any of the four, in addition to the scope's own key |

### Account

| Key | Values | Gates |
| --- | --- | --- |
| `workspaces` | boolean | `workspaces.list` |
| `tags` | boolean | `tags.list` |
| `blocklist` | boolean | `blocklist.list` |
| `webhooks.list` | boolean | `webhooks.list` and `campaign(id).webhooks` |
| `native` | boolean | `outbound.native`: see [native reads](/docs/native) |

### Webhooks and sync

These describe the adapter. No call on the instance is gated on them.

| Key | Values | Says |
| --- | --- | --- |
| `events.parse` | boolean | The vendor's webhook payloads can be parsed into events. See [webhooks](/docs/webhooks). |
| `events.verify` | `signature`, `secret`, `false` | How a webhook is authenticated: a signature the vendor computes, or a shared secret you put in the URL or a header. See [verification](/docs/webhooks/verification). |
| `deletionDetection` | boolean | Whether list calls reveal deleted records. |

## Filter grades

A filter key is `server`, `client` or `false`.

| Grade | What happens | Result is marked |
| --- | --- | --- |
| `server` | The vendor filters. | `exact` |
| `client` | The vendor cannot. The SDK fetches each page unfiltered and filters it before handing it to you. | `derived` |
| `false` | The filter is refused with `Unsupported`. |  |

```ts title="grades.ts"
import { createOutbound } from "outbound-sdk";
import type { FilterGrade } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

// The same read on three adapters that differ in one grade.
const read = async (grade: FilterGrade): Promise<void> => {
  const outbound = createOutbound({
    adapter: memory({
      capabilities: { "campaigns.status": grade },
      data: demoData(),
    }),
  });

  try {
    const page = await outbound.campaigns.list({ status: "paused" });

    console.log(
      `${grade}:`,
      page.items.map((campaign) => campaign.name),
      page.meta.evidence
    );
  } catch (error) {
    console.log(`${grade}:`, error instanceof Error ? error.message : error);
  }
};

await read("server");

await read("client");

await read(false);
```

```text title="Output"
server: [ 'Agency owners' ] exact
client: [ 'Agency owners' ] derived
false: Filtering campaigns by status is not available: the memory adapter does not support it (campaigns.status)
```

A `client` filter gives the right records and costs more to get them:

- **Every page is fetched.** Asking for the last day of leads still walks every lead. The cost is that of the unfiltered listing.
- **Pages come back short.** A page can hold fewer records than `limit`, or none, with more pages to come. Go by `cursor`, not by how many items arrived. See [pagination](/docs/api/pagination#limit).
- **`meta.evidence` is `derived`** on that result, so code downstream can tell. See [results](/docs/api/results#evidence).

Without the filter the same listing is `exact`. The grade only applies when the filter is used.

## Stat grades

A stat key is `exact`, `derived` or `false`: the vendor reports the numbers, the adapter computes them from other vendor data, or they are not available. A stats result is `derived` when any of its rows is. See [stats](/docs/api/stats#grades).

## Change what an adapter declares

Each vendor adapter takes a `capabilities` option that overrides what it declares. Use it when your account differs from the default: a plan without an endpoint, or a key that cannot read one.

```ts title="override.ts"
import { createOutbound } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

const declared = createOutbound({ adapter: instantly() });

// A workspace on a plan without webhooks, with a key that cannot read tags.
const mine = createOutbound({
  adapter: instantly({
    capabilities: { tags: false, "webhooks.list": false },
  }),
});

for (const key of ["tags", "webhooks.list", "sequence"] as const) {
  console.log(key, declared.capabilities[key], "->", mine.capabilities[key]);
}
```

```text title="Output"
tags true -> false
webhooks.list true -> false
sequence true -> true
```

A key you turn off is refused up front like any other. On Instantly, turning `tags` off also stops the two extra requests a campaign listing makes to read tag names, and campaigns then come back with empty `tags`.

A declaration is a claim. Turning a key on does not give a vendor an endpoint it lacks: the call then fails, as `Unsupported` when the adapter has no read for it, or with whatever the vendor answers.

The [in-memory adapter](/docs/adapters/memory) takes the same option. That is how the samples on this site run code "as" a vendor: see [how the samples are run](/docs/samples).

## Plugins can only narrow

A plugin may change the map the instance advertises, and only downwards. A plugin that turns something off is named in the refusal. A plugin that tries to turn something on is ignored.

```ts title="plugins.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

// A plugin's `capabilities` hook returns the map to advertise.
const noBodies: OutboundPlugin = {
  capabilities: (capabilities) => ({ ...capabilities, "messages.body": false }),
  name: "no-bodies",
};

// This one tries to add what the adapter never declared.
const wishful: OutboundPlugin = {
  capabilities: (capabilities) => ({ ...capabilities, "senders.health": true }),
  name: "wishful",
};

const outbound = createOutbound({
  adapter: memory({
    capabilities: { "senders.health": false },
    data: demoData(),
  }),
  plugins: [noBodies, wishful],
});

console.log("messages.body:", outbound.capabilities["messages.body"]);

console.log("senders.health:", outbound.capabilities["senders.health"]);

try {
  await outbound.replies.list({ include: ["body"] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.message);
  }
}
```

```text title="Output"
messages.body: false
senders.health: false
Reading reply bodies is not available: the no-bodies plugin turned it off (messages.body)
```

`outbound.capabilities` is the map after every plugin. The [redact](/docs/plugins/redact) plugin uses this: it turns off `native`, and `messages.body` when bodies are being masked, so a read that would return unmasked text is refused and not quietly passed through.

A [combined](/docs/adapters/combine) instance works the same way. Its map holds, for each key, the weakest value among its adapters, so it promises only what every one of them can do. `outbound.workspace(id).capabilities` is the map for one of them.

## Helpers

Exported from `outbound-sdk` for code that works with the maps directly, such as an adapter or a plugin:

| Export | Does |
| --- | --- |
| `CAPABILITY_KEYS` | Every key, as an array. |
| `NO_CAPABILITIES` | A map with every key `false`. |
| `resolveCapabilities(declared?)` | A partial declaration filled out with `false`. |
| `intersectCapabilities(first, ...rest)` | For each key, the weakest value across the maps. |

"Weakest" orders the values: `false` is below `client`, `derived`, `sampled` and `secret`, which are below `server`, `exact`, `full`, `signature`, `vendor` and `true`.
