Stats
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.
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, and meta.
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",
},
],
};
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);
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.
fromandtoare calendar days, not timestamps. Anything else, such as2026-11-09T00:00:00Z, isInvalid. So is afromafterto, and a scope other thanworkspacewithout ascopeId.- With no
fromorto, 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 itsstepIndex.- A scope with nothing to report returns no rows. An empty
rowsis 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 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.
| 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. |
What a vendor counts as an open or a reply differs in the details. Each adapter’s caveats and its adapter page 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.
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);
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, soacceptanceRateisnull. - Read
basisbefore comparing two rates. A reply rate overcontactedand one oversentare different numbers for the same campaign. - An open rate is
nullwhen 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 are computed on top of these reads: comparisons between periods, variant tests, funnels.
- Rate limits: a stats read is one or more vendor requests like any other.