---
title: Stats
description: How to read counts for a workspace, a campaign, a campaign's steps or a sender, as one total or one row per day, and how the six rates are computed from those counts.
sidebar:
  label: Stats
related:
  - /docs/api/records
  - /docs/capabilities
  - /docs/insights
---

Stats are counts: how many were sent, opened, replied, bounced. The SDK returns counts only and computes every rate from them with one set of definitions, so a reply rate means the same thing on every vendor. A vendor's own percentages are never passed through.

## Three ways in

| Call | Scope |
| --- | --- |
| `outbound.stats(options?)` | The workspace by default, or any scope with `scope` and `scopeId` |
| `outbound.campaign(id).stats(options?)` | That campaign, or its steps with `scope: "step"` |
| `outbound.sender(id).stats(options?)` | That sender |

All three return a `StatsResult`: `rows`, an array of [`MetricRow`](#the-counts), and [`meta`](/docs/api/results).

```ts title="seed.ts"
import type { MemoryData } from "outbound-sdk/memory";

// One campaign with two steps and two mailboxes, over two days. A row with
// `stepIndex` is a per-step row, a row with `senderId` is a per-sender row,
// and a row with neither is the campaign's own daily row.
export const seed: MemoryData = {
  campaigns: [{ id: "cmp_launch", name: "Launch week" }],
  senders: [
    { address: "ana@example.com", id: "mbx_ana" },
    { address: "ben@example.com", id: "mbx_ben" },
  ],
  stats: [
    {
      campaignId: "cmp_launch",
      counts: {
        bounced: 4,
        contacted: 180,
        delivered: 196,
        opened: 98,
        positive: 3,
        replied: 9,
        sent: 200,
        unsubscribed: 1,
      },
      date: "2026-11-09",
    },
    {
      campaignId: "cmp_launch",
      counts: {
        bounced: 2,
        contacted: 120,
        delivered: 148,
        opened: 60,
        positive: 3,
        replied: 6,
        sent: 150,
        unsubscribed: 0,
      },
      date: "2026-11-10",
    },
    {
      campaignId: "cmp_launch",
      counts: { replied: 11, sent: 300 },
      date: "2026-11-10",
      stepIndex: 0,
    },
    {
      campaignId: "cmp_launch",
      counts: { replied: 4, sent: 50 },
      date: "2026-11-10",
      stepIndex: 1,
    },
    {
      campaignId: "cmp_launch",
      counts: { bounced: 1, sent: 210 },
      date: "2026-11-10",
      senderId: "mbx_ana",
    },
    {
      campaignId: "cmp_launch",
      counts: { bounced: 5, sent: 140 },
      date: "2026-11-10",
      senderId: "mbx_ben",
    },
  ],
};
```

```ts title="scopes.ts"
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";

import { seed } from "./seed.ts";

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

// The whole workspace, one total row.
const workspace = await outbound.stats();

console.log("workspace:", workspace.rows[0]?.sent, "sent");

// One campaign, one row per day.
const daily = await outbound.campaign("cmp_launch").stats({
  by: "day",
  from: "2026-11-09",
  to: "2026-11-10",
});

for (const row of daily.rows) {
  console.log(row.date, row.sent, "sent,", row.replied, "replied");
}

// One row per step of the campaign.
const steps = await outbound.campaign("cmp_launch").stats({ scope: "step" });

for (const row of steps.rows) {
  console.log("step", row.stepIndex, row.sent, "sent,", row.replied, "replied");
}

// One mailbox.
const sender = await outbound.sender("mbx_ben").stats();

console.log(
  "mbx_ben:",
  sender.rows[0]?.sent,
  "sent,",
  sender.rows[0]?.bounced,
  "bounced"
);

// The same reads through the top-level method.
const same = await outbound.stats({ scope: "sender", scopeId: "mbx_ben" });

console.log(same.rows[0]?.scope, same.rows[0]?.scopeRef, same.meta.evidence);
```

```text title="Output"
workspace: 350 sent
2026-11-09 200 sent, 9 replied
2026-11-10 150 sent, 6 replied
step 0 300 sent, 11 replied
step 1 50 sent, 4 replied
mbx_ben: 140 sent, 5 bounced
sender memory:default:mbx_ben exact
```

## Options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `scope` | `"workspace"`, `"campaign"`, `"step"`, `"sender"` | `"workspace"` | What the counts are for. On `campaign(id).stats` only `"campaign"` (the default) and `"step"`. Not an option on `sender(id).stats`. |
| `scopeId` | `string` | none | The campaign for the `campaign` and `step` scopes, the sender for `sender`. Only on `outbound.stats`: the handles fill it in. |
| `from` | `YYYY-MM-DD` | none | First day, inclusive. |
| `to` | `YYYY-MM-DD` | none | Last day, inclusive. |
| `by` | `"total"` or `"day"` | `"total"` | One row over the range, or one row per day. |

Plus the per-call options every read takes: see [retries](/docs/retries).

- `from` and `to` are calendar days, not timestamps. Anything else, such as `2026-11-09T00:00:00Z`, is `Invalid`. So is a `from` after `to`, and a scope other than `workspace` without a `scopeId`.
- With no `from` or `to`, the SDK passes no range and the adapter decides what the vendor is asked for. Pass both when the number matters.
- `scope: "step"` returns one row per step, each with its `stepIndex`.
- A scope with nothing to report returns no rows. An empty `rows` is not a row of zeros.

## Scopes and capabilities

Each scope needs a capability, and `by: "day"` needs one more. A scope the adapter cannot serve is refused with `Unsupported` before any request.

| Request                   | Needs                 |
| ------------------------- | --------------------- |
| `scope: "workspace"`      | `stats.workspace`     |
| `scope: "campaign"`       | `stats.campaign`      |
| `scope: "step"`           | `stats.byStep`        |
| `scope: "sender"`         | `stats.bySender`      |
| `by: "day"`, on any scope | `stats.byDay` as well |

### Grades

A stat capability is `"exact"`, `"derived"` or `false`.

| Grade | Meaning |
| --- | --- |
| `exact` | The vendor reports these counts for this scope, and the adapter passes them on. |
| `derived` | The adapter computes them from other vendor data, for example by adding up daily rows. |
| `false` | Not available. |

Each row says which it was in its own `evidence` field, and `meta.evidence` is `derived` as soon as one row is not `exact`. The [adapter pages](/docs/adapters) list each vendor's grades.

## The counts

Every count on a `MetricRow` is a non-negative number, or `null` when the vendor does not report it. See [null is not zero](/docs/api/records#null-is-not-zero).

| Field | Counts |
| --- | --- |
| `sent` | Messages sent: email sends, or LinkedIn messages. |
| `delivered` | Messages delivered. |
| `contacted` | Unique leads contacted. |
| `opened` | Unique opens. `null` when open tracking is off. |
| `openedTotal` | Every open, repeats included. |
| `clicked` | Clicks. |
| `replied` | Replies from people. Auto-replies are counted in `repliedAuto` where the vendor separates them. |
| `repliedAuto` | Automatic replies. |
| `positive` | Replies the vendor marked positive. |
| `bounced` | Messages that bounced. |
| `unsubscribed` | Unsubscribes. |
| `connectionsSent` | LinkedIn connection requests sent. LinkedIn only. |
| `connectionsAccepted` | LinkedIn connection requests accepted. LinkedIn only. |
| `inmailsSent` | LinkedIn InMails sent. LinkedIn only. |

The other fields say what the row is:

| Field | Type | Holds |
| --- | --- | --- |
| `scope` | `"workspace"`, `"campaign"`, `"step"`, `"sender"` |  |
| `scopeRef` | `string` | The ref of the campaign or sender the row is for. |
| `date` | `YYYY-MM-DD` or `null` | The day, for a daily row. `null` for a total. |
| `timezone` | `string` or `null` | The vendor's timezone for a daily row. A vendor's "day" is not always UTC. |
| `stepIndex` | integer or `null` | The step, for a per-step row. |
| `variantKey` | `string` or `null` | The variant, for a per-variant row. |
| `contentHash` | `string` or `null` | The hash of the copy the row is for, where known. |
| `evidence` | `"exact"`, `"derived"`, `"sampled"` | How this row was obtained. |
| `provider`, `workspaceId`, `schemaVersion` |  | As on every [record](/docs/api/records). |

What a vendor counts as an open or a reply differs in the details. Each adapter's `caveats` and its [adapter page](/docs/adapters) record what is known.

## Rates

`computeRates(row)` returns six rates for one row. Each is `{ value, basis }`: `value` between 0 and 1, and `basis` the count it was divided by.

```ts title="rates.ts"
import { computeRates, createOutbound, METRIC_DEFINITIONS } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";

import { seed } from "./seed.ts";

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

const { rows } = await outbound.campaign("cmp_launch").stats({
  from: "2026-11-09",
  to: "2026-11-10",
});

const [total] = rows;

if (total !== undefined) {
  console.log(total.sent, "sent,", total.contacted, "contacted\n");

  const rates = computeRates(total);

  for (const [name, rate] of Object.entries(rates)) {
    const percent =
      rate.value === null ? "null" : `${(rate.value * 100).toFixed(1)}%`;

    console.log(`${name}: ${percent} of ${rate.basis}`);
  }
}

console.log(`\n${METRIC_DEFINITIONS.replyRate.name}:`);

console.log(METRIC_DEFINITIONS.replyRate.description);
```

```text title="Output"
350 sent, 300 contacted

acceptanceRate: null of null
bounceRate: 1.7% of sent
openRate: 45.9% of delivered
positiveReplyRate: 2.0% of contacted
replyRate: 5.0% of contacted
unsubscribeRate: 0.3% of contacted

Reply rate:
Replies from people, excluding auto-replies where the vendor separates them, divided by unique leads contacted (or messages sent when contacted is unknown).
```

| Rate | Numerator | Divided by |
| --- | --- | --- |
| `replyRate` | `replied` | `contacted`, or `sent` when `contacted` is missing |
| `positiveReplyRate` | `positive` | `contacted`, or `sent` |
| `unsubscribeRate` | `unsubscribed` | `contacted`, or `sent` |
| `openRate` | `opened` | `delivered`, or `sent` when `delivered` is missing |
| `bounceRate` | `bounced` | `sent` |
| `acceptanceRate` | `connectionsAccepted` | `connectionsSent` |

- A rate is `{ value: null, basis: null }` when its numerator is missing, or when every base it could use is missing or 0. The sample has no LinkedIn counts, so `acceptanceRate` is `null`.
- Read `basis` before comparing two rates. A reply rate over `contacted` and one over `sent` are different numbers for the same campaign.
- An open rate is `null` when open tracking is off. Where it exists, mail privacy features inflate it.

`METRIC_DEFINITIONS` holds a `name` and a one-sentence `description` for each rate, keyed by the same six names. Use them as labels and tooltips so a report says what its numbers mean.

## Adding rows

`sumCounts(rows)` adds rows of counts together. A count stays `null` only when no row reports it. `emptyCounts()` is a row with every count `null`.

Unique counts (`opened`, `contacted`) are summed as they are, which over-counts a person who appears in more than one row. A total from the vendor (`by: "total"`) is the better number when the vendor has one.

## Related

- [Insights](/docs/insights) are computed on top of these reads: comparisons between periods, variant tests, funnels.
- [Rate limits](/docs/rate-limits): a stats read is one or more vendor requests like any other.
