---
title: Handles
description: What campaign(id), sender(id), lead(id), list(id) and thread(id) give you, what each method returns, and the capability each one needs.
sidebar:
  label: Handles
related:
  - /docs/api/collections
  - /docs/api/stats
  - /docs/api/records
---

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)`](#campaign) | A campaign, its sequence, settings, senders, leads, messages, replies and stats |
| [`outbound.sender(id)`](#sender) | A mailbox or LinkedIn seat, its warmup, health and stats |
| [`outbound.lead(id)`](#lead) | A lead and every message to and from it |
| [`outbound.list(id)`](#list) | The leads on a saved list |
| [`outbound.thread(id)`](#thread) | Every message of one conversation |

The `id` is the vendor's own id or a record's `ref`. On a [combined](/docs/adapters/combine) 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](/docs/retries).

## Campaign

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

```text title="Output"
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](/docs/api/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](/docs/api/stats) page.

## Sender

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

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

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

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

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

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