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.