Capabilities
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.
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.
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(" | ")
);
}
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 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.
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());
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 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 |
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. |
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. |
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. |
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);
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 bycursor, not by how many items arrived. See pagination. meta.evidenceisderivedon that result, so code downstream can tell. See results.
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.
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.
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]);
}
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 takes the same option. That is how the samples on this site run code “as” a vendor: see how the samples are run.
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.
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);
}
}
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 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 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.