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

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 by cursor, not by how many items arrived. See pagination.
  • meta.evidence is derived on 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.

Was this page helpful?