Collections
The five read methods every collection shares, the filters each collection takes and the capability each filter needs, lookups by email or profile, message bodies, and bulk reads where a missing record is data.
A collection is a kind of record you can list: outbound.campaigns, outbound.leads and eight more. All ten have list and listAll. Campaigns, senders and leads also have get, exists and getMany.
The methods
| Method | Returns | Notes |
|---|---|---|
list(options?) |
Promise<PageResult<Item>> |
One page: items, cursor, complete and meta. |
listAll(options?) |
ListAll<Item> |
An async iterator over every page. Pages are fetched as you iterate, under the rate budget. |
get(id, options?) |
Promise<WithMeta<Item>> |
One record with meta on it. Throws NotFound for a missing record. |
exists(id, options?) |
Promise<boolean> |
false for a missing record. Any other failure still throws. |
getMany(ids, options?) |
Promise<BulkResult<Item>> |
Several records at once. A record that fails is listed, not thrown. |
options on list and listAll holds the collection’s filters, the paging options (limit, cursor, and max on listAll) and the per-call options (timeout, retries, deadline, maxRequests, signal). Paging is covered on the pagination page and the per-call options on the retries page.
An id is the vendor’s own id or the ref of a record. On a combined instance it must be a ref.
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
const outbound = createOutbound({ adapter: memory({ data: demoData() }) });
// One page. Filters and paging options go in the same object.
const active = await outbound.campaigns.list({ limit: 10, status: "active" });
console.log(
"active:",
active.items.map((campaign) => campaign.name)
);
// `since` and `until` are inclusive ISO 8601 dates or timestamps.
const recent = await outbound.leads.list({
campaignId: "cmp_founders",
since: "2026-10-03",
});
console.log(
"added since 3 October:",
recent.items.map((lead) => lead.email)
);
const senders = await outbound.senders.list({ campaignId: "cmp_agencies" });
console.log(
"senders on cmp_agencies:",
senders.items.map((sender) => sender.address)
);
const thread = await outbound.messages.list({ threadId: "thr_1" });
console.log(
"thr_1:",
thread.items.map((message) => message.direction)
);
// The collections with no filters.
const lists = await outbound.lists.list();
const tags = await outbound.tags.list();
const blocked = await outbound.blocklist.list();
const webhooks = await outbound.webhooks.list();
const workspaces = await outbound.workspaces.list();
console.log(
lists.items[0]?.name,
"|",
tags.items[0]?.name,
"|",
blocked.items[0]?.value,
"|",
webhooks.items[0]?.events,
"|",
workspaces.items[0]?.name
);
active: [ 'Q4 SaaS founders' ]
added since 3 October: [ 'person2@example.com' ]
senders on cmp_agencies: [ 'outreach2@example.com' ]
thr_1: [ 'out', 'in' ]
SaaS founders | client-a | competitor.example | [ 'reply_received' ] | default
Filters
A filter the adapter cannot serve is refused with Unsupported before any request. The capability column says which key decides that. Check outbound.capabilities first when your code must run on more than one vendor: see capabilities.
campaigns
| Filter | Type | Needs |
|---|---|---|
status |
"active", "paused", "draft", "completed", "archived" or "unknown" |
campaigns.status |
campaigns.list and campaigns.get need no capability. Every adapter has them.
senders
| Filter | Type | Needs |
|---|---|---|
| none | senders.list |
|
campaignId |
string |
senders.byCampaign, and not senders.list |
senders.get needs senders.get.
leads
| Filter | Type | Needs |
|---|---|---|
| none | leads.list |
|
campaignId |
string |
leads.byCampaign, and not leads.list |
listId |
string |
lists.leads, and not leads.list |
email |
string |
leads.lookup |
linkedinUrl |
string |
leads.lookup |
since, until |
ISO 8601 date or timestamp, inclusive | leads.since |
campaignId and listId together are refused with Invalid. The date filters compare a lead’s createdAt. leads.get needs leads.get.
Some vendors have no list of every lead: leads exist only inside a campaign. That is why the scoped listings do not depend on leads.list.
messages
| Filter | Type | Needs |
|---|---|---|
| any | messages.list |
|
campaignId |
string |
|
leadId |
string |
|
threadId |
string, the threadId of an earlier message |
messages.byThread |
since, until |
ISO 8601 date or timestamp, inclusive | messages.since |
include: ["body"] |
messages.body |
The date filters compare sentAt.
replies
| Filter | Type | Needs |
|---|---|---|
| any | replies.list |
|
campaignId |
string |
|
category |
"positive", "negative", "neutral", "ooo", "bounce", "unsubscribe" or "unknown" |
replies.category |
since, until |
ISO 8601 date or timestamp, inclusive | replies.since |
include: ["body"] |
messages.body |
The rest
| Collection | Filter | Needs |
|---|---|---|
lists |
none | lists.list |
tags |
none | tags |
blocklist |
none | blocklist |
webhooks |
campaignId |
webhooks.list |
workspaces |
none | workspaces |
Dates
since and until take anything Date.parse reads, such as 2026-10-03 or 2026-10-03T08:00:00Z. Both ends are inclusive, and the SDK turns each into a UTC timestamp before the adapter sees it. A value it cannot read, or a since after until, is Invalid.
Where a vendor cannot filter by date, the adapter declares the filter as client. The SDK then filters each page after fetching it and marks the result derived. A page of such a listing can hold fewer records than you asked for, or none, while more pages remain. See filter grades.
Lookups
email and linkedinUrl on leads find one person without walking every lead. They match the exact address or profile. The SDK trims the value and lowercases an email before the lookup.
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
const outbound = createOutbound({ adapter: memory({ data: demoData() }) });
// A lookup by exact address. The SDK trims and lowercases it first.
const found = await outbound.leads.list({ email: " Person1@Example.com " });
for (const lead of found.items) {
console.log(lead.ref, lead.campaignRef, lead.status);
}
const nobody = await outbound.leads.list({ email: "nobody@example.com" });
console.log("no match:", nobody.items.length, nobody.complete);
memory:default:lead_1 memory:default:cmp_founders replied
no match: 0 true
- A lookup returns one record per campaign the person is in, so one address can come back several times. Each record has its own
campaignRef. - A lookup alone needs
leads.lookupand notleads.list. AddcampaignIdorlistIdto narrow it. - No match is an empty page, not an error.
What a vendor matches on is an adapter fact. The adapter pages say which of the two each vendor can look up.
Message bodies
Message and Reply have bodyText and bodyHtml. Both are null unless the call asks for bodies with include: ["body"], because on some vendors a body costs extra requests.
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
const outbound = createOutbound({ adapter: memory({ data: demoData() }) });
// Bodies are left out unless you ask for them.
const plain = await outbound.replies.list({ category: "positive" });
console.log("without include:", plain.items[0]?.bodyText);
const withBody = await outbound.replies.list({
category: "positive",
include: ["body"],
});
console.log("with include:", withBody.items[0]?.bodyText);
without include: null
with include: Thanks, happy to chat next week.
"body" is the only value include takes. Asking for bodies on an adapter whose messages.body is false is refused with Unsupported. A body can still be null after you asked: the vendor did not return one for that message.
One record, and several
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
const outbound = createOutbound({ adapter: memory({ data: demoData() }) });
const campaign = await outbound.campaigns.get("cmp_founders");
console.log(campaign.name, campaign.meta.evidence);
console.log(await outbound.campaigns.exists("cmp_founders"));
console.log(await outbound.campaigns.exists("cmp_gone"));
// A missing record does not fail the batch. It is listed under `errors`.
const { errors, results } = await outbound.campaigns.getMany(
["cmp_agencies", "cmp_gone", "cmp_founders"],
{ concurrency: 2 }
);
for (const { id, value } of results) {
console.log("ok:", id, value.name);
}
for (const { error, id } of errors) {
console.log("failed:", id, error.code, "-", error.message);
}
Q4 SaaS founders exact
true
false
ok: cmp_agencies Agency owners
ok: cmp_founders Q4 SaaS founders
failed: cmp_gone NotFound - Campaign cmp_gone was not found
getMany returns two lists, each in the order of the ids you passed:
| Field | Holds |
|---|---|
results |
{ id, value } for each record that was read. value is the record with its meta. |
errors |
{ id, error } for each that was not. error is an OutboundError. |
- It does not throw for a record that fails. A missing id, a rate limit and a vendor error all land in
errors. Checkerrors.lengthbefore you treat the batch as whole. - It does throw, before any request, when the adapter cannot read that kind of record at all.
concurrencyis how many records are fetched at once. The default is 4. The rate budget still applies to every request.- The per-call options cover the whole batch.
maxRequests: 10is ten requests for all the ids together, and the ids past the budget come back inerrorsasRateLimited.