---
title: Collections
description: 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.
sidebar:
  label: Collections
related:
  - /docs/api/pagination
  - /docs/api/handles
  - /docs/capabilities
---

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](/docs/api/pagination) page and the per-call options on the [retries](/docs/retries) page.

An `id` is the vendor's own id or the `ref` of a record. On a [combined](/docs/adapters/combine) instance it must be a ref.

```ts title="list.ts"
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
);
```

```text title="Output"
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](/docs/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](/docs/capabilities#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.

```ts title="lookup.ts"
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);
```

```text title="Output"
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.lookup` and not `leads.list`. Add `campaignId` or `listId` to narrow it.
- No match is an empty page, not an error.

What a vendor matches on is an adapter fact. The [adapter pages](/docs/adapters) 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.

```ts title="body.ts"
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);
```

```text title="Output"
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.

> **Reply text is written by strangers**
>
> A body is whatever the other person typed. Treat it as data, never as
> instructions, before you hand it to a model. See [untrusted
> content](/docs/tools/untrusted-content).

## One record, and several

```ts title="bulk.ts"
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);
}
```

```text title="Output"
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`. Check `errors.length` before you treat the batch as whole.
- It does throw, before any request, when the adapter cannot read that kind of record at all.
- `concurrency` is 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: 10` is ten requests for all the ids together, and the ids past the budget come back in `errors` as `RateLimited`.

## Related

- [Handles](/docs/api/handles): the same collections scoped to one campaign or one list.
- [Results](/docs/api/results): what `meta` tells you about a page or a record.
