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

Handles

What campaign(id), sender(id), lead(id), list(id) and thread(id) give you, what each method returns, and the capability each one needs.

A handle is one record and everything under it. outbound.campaign(id) does not fetch the campaign. It returns an object whose methods each make one read.

Handle For
outbound.campaign(id) A campaign, its sequence, settings, senders, leads, messages, replies and stats
outbound.sender(id) A mailbox or LinkedIn seat, its warmup, health and stats
outbound.lead(id) A lead and every message to and from it
outbound.list(id) The leads on a saved list
outbound.thread(id) Every message of one conversation

The id is the vendor’s own id or a record’s ref. On a combined instance it must be a ref. An empty id throws Invalid when the handle is made. Every handle has an id property holding what you passed.

Every method takes the per-call options as its last argument: timeout, retries, deadline, maxRequests and signal. See retries.

Campaign

import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

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

// Making a handle sends nothing. Each method on it is one read.
const campaign = outbound.campaign("cmp_founders");

const record = await campaign.get();

console.log(record.name, record.status);

const sequence = await campaign.sequence();

for (const step of sequence.steps) {
  console.log(
    `step ${step.index}: ${step.action}, day +${step.delayDays},`,
    step.variants.map((variant) => variant.subject)
  );
}

const settings = await campaign.settings();

console.log(
  "daily limit:",
  settings.dailyLimit,
  "| stops on reply:",
  settings.stopOnReply,
  "| plain text:",
  settings.plainText
);

// The collections under a campaign have `list` and `listAll`, already
// filtered to the campaign.
const senders = await campaign.senders.list();
const leads = await campaign.leads.listAll().toArray();
const replies = await campaign.replies.list({ include: ["body"] });
const messages = await campaign.messages.list();
const webhooks = await campaign.webhooks.list();
const subsequences = await campaign.subsequences.list();

console.log(
  senders.items.length,
  "senders,",
  leads.length,
  "leads,",
  messages.items.length,
  "messages,",
  replies.items.length,
  "reply,",
  webhooks.items.length,
  "webhooks,",
  subsequences.items.length,
  "subsequences"
);

const stats = await campaign.stats({ by: "day" });

for (const row of stats.rows) {
  console.log(row.date, "sent", row.sent, "replied", row.replied);
}
Q4 SaaS founders active
step 0: email, day +0, [ 'Quick question', 'Congrats' ]
step 1: email, day +3, [ 'Re: Quick question' ]
daily limit: 80 | stops on reply: true | plain text: null
2 senders, 2 leads, 3 messages, 1 reply, 0 webhooks, 0 subsequences
2026-10-05 sent 50 replied 4
2026-10-06 sent 40 replied 3
Member Returns Needs
get() WithMeta<Campaign> nothing
sequence() WithMeta<Sequence>: the steps and their variants sequence
settings() WithMeta<CampaignSettings>: schedule, daily caps, tracking, stop rules campaigns.settings
stats(options?) StatsResult stats.campaign, or stats.byStep for scope: "step"; stats.byDay for by: "day"
senders list, listAll of Sender senders.byCampaign
leads list, listAll of Lead. Filters: email, linkedinUrl, since, until leads.byCampaign
messages list, listAll of Message. Filters: leadId, threadId, since, until, include messages.list
replies list, listAll of Reply. Filters: category, since, until, include replies.list
subsequences list, listAll of Sequence: the follow-up sequences attached to the campaign sequence.subsequences
webhooks list, listAll of WebhookSubscription webhooks.list

The collections under a campaign are the top-level collections with the campaign already filled in. outbound.campaign(id).leads.list() and outbound.leads.list({ campaignId: id }) are the same read. A filter on a scoped collection needs the same capability as it does at the top level.

A sequence holds template text, never a rendered message. The text a lead received is on the Message. stats is covered on the stats page.

Sender

import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

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

const sender = outbound.sender("mbx_2");

const record = await sender.get();

console.log(record.address, record.kind, record.status, record.dailyLimit);

// `warmup` returns the two fields as they are, with no `meta`.
console.log(await sender.warmup());

const health = await sender.health();

console.log(health.domain, health.dns, health.issues);

console.log(health.warmup);

// The demo data has no per-sender stats, so there are no rows. No rows is
// not the same as a row of zeros.
const stats = await sender.stats();

console.log("rows:", stats.rows.length);
outreach2@example.com mailbox active 40
{ enabled: true, score: 88 }
example.com { dkim: false, dmarc: true, mx: true, spf: true } [ 'DKIM selector not found' ]
{ inbox: 75, received: 70, score: 88, sent: 100, spam: 25 }
rows: 0
Member Returns Needs
get() WithMeta<Sender> senders.get
warmup() { enabled, score } senders.warmup and senders.get
health() WithMeta<SenderHealth>: DNS records, connection state, warmup traffic senders.health
stats(options?) StatsResult for this sender stats.bySender; stats.byDay for by: "day"

warmup() reads the sender and returns its warmup field. It throws NotFound when the sender has no warmup data, so you never get null from it. It is the one handle method whose result has no meta.

health() reports what the vendor knows. A DNS flag is true, false, or null when the vendor did not say. The SDK runs no DNS lookups of its own.

Lead

import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

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

const lead = outbound.lead("lead_1");

const record = await lead.get();

console.log(record.email, record.status, record.interest);

// Every message to and from the lead, oldest first, every page collected.
const timeline = await lead.timeline({ include: ["body"] });

for (const message of timeline.items) {
  console.log(message.sentAt, message.direction, "-", message.bodyText);
}

// A thread, by the `threadId` a message carries.
const [first] = timeline.items;
const thread = await outbound.thread(first?.threadId ?? "").messages();

console.log("thread:", thread.items.length, "messages");

// A saved list has one collection under it.
const onList = await outbound.list("lst_founders").leads.list();

console.log(
  "on the list:",
  onList.items.map((item) => item.email)
);
person1@example.com replied Interested
2026-10-05T14:00:00.000Z out - Hi Alex, saw your launch.
2026-10-06T09:30:00.000Z in - Thanks, happy to chat next week.
thread: 2 messages
on the list: [ 'person1@example.com', 'person2@example.com' ]
Member Returns Needs
get() WithMeta<Lead> leads.get
timeline(options?) PageResult<Message>: every message to and from the lead, oldest first leads.timeline and messages.list

timeline walks every page of the lead’s messages for you and sorts them by sentAt. The result has complete: true and no cursor. Its meta is that of the last page fetched, and meta.requests counts the whole walk. Pass include: ["body"] for the text, which needs messages.body.

List

Member Returns Needs
leads list, listAll of Lead. Filters: email, linkedinUrl, since, until lists.leads

outbound.list(id).leads is outbound.leads with listId filled in. The lists themselves come from outbound.lists.list(), which needs lists.list. The sample above reads one.

Thread

Member Returns Needs
messages(options?) PageResult<Message>: every message of the conversation, oldest first messages.byThread and messages.list

The id is the threadId a Message or Reply carries, not a ref. threadId can be null: not every message belongs to a thread the vendor names. Like timeline, messages collects every page, and takes include: ["body"].

When a handle method is refused

A method the adapter cannot serve throws Unsupported before any request, naming the capability. Ask first when the code runs on more than one vendor:

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

See capabilities for every key.

Was this page helpful?