Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

  • 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 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, 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.

  • 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.

Was this page helpful?