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

In-memory adapter

Run the whole SDK on plain data with no key and no network, seed every record type, limit it to what a real vendor declares, and know where it differs from a vendor.

memory() from outbound-sdk/memory is a complete adapter over data you pass in. Use it in tests, in demos and to try a call before you have a key. Most samples on this site run on it.

Options

Option Default What it is
data {} The seed: a MemoryData object, described below.
workspaceId default The workspace label records carry. It may not contain a colon.
name memory The provider name records carry.
pageSize 50 Records per page, to exercise paging with a small seed.
capabilities everything it can serve Overrides single capabilities, to exercise gating and fallbacks.

Seed data

Every array in MemoryData is optional, and on most seed types only id is required. A field you leave out gets a default (status: "active", channel: "email", a name equal to the id) or null.

import type { MemoryData } from "outbound-sdk/memory";

// Every seed type the in-memory adapter takes. Only `id` is required on most
// of them; everything left out gets a default or `null`.
export const seed: MemoryData = {
  blocklist: [
    // `kind` defaults to `email` when the value has an @, else `domain`.
    { id: "blk_1", value: "rival.example" },
    { id: "blk_2", value: "optout@example.com" },
  ],
  campaigns: [
    {
      createdAt: "2026-10-26T09:00:00.000Z",
      id: "cmp_ops",
      name: "Ops leaders",
      settings: {
        dailyLimit: 60,
        schedule: [
          {
            days: [1, 2, 3, 4, 5],
            from: "09:00",
            timezone: "Europe/London",
            to: "17:00",
          },
        ],
        stopOnReply: true,
        trackOpens: false,
      },
      status: "active",
      steps: [
        {
          delayDays: 0,
          variants: [
            { body: "Hi, a quick question.", subject: "Question" },
            { body: "Hi, one idea for you.", subject: "Idea" },
          ],
        },
        { delayDays: 3, variants: [{ body: "Following up." }] },
      ],
      subsequences: [
        {
          id: "sub_ooo",
          name: "Back from leave",
          steps: [{ delayDays: 7, variants: [{ body: "Welcome back." }] }],
        },
      ],
      tags: ["acme"],
    },
    { channel: "linkedin", id: "cmp_li", status: "paused" },
  ],
  leads: [
    {
      campaignId: "cmp_ops",
      company: "Example Co",
      createdAt: "2026-11-02T10:00:00.000Z",
      custom: { region: "EMEA" },
      email: "person1@example.com",
      engagement: { opens: 3, replies: 1, stepIndex: 1 },
      firstName: "Alex",
      id: "lead_1",
      interest: "Interested",
      lastName: "Example",
      listIds: ["lst_ops"],
      status: "replied",
      tags: ["priority"],
      title: "COO",
    },
    {
      campaignId: "cmp_ops",
      createdAt: "2026-11-09T10:00:00.000Z",
      email: "person2@example.com",
      id: "lead_2",
      listIds: ["lst_ops"],
    },
    {
      campaignId: "cmp_li",
      id: "lead_3",
      linkedinUrl: "https://www.linkedin.com/in/example-person-3",
    },
  ],
  lists: [{ id: "lst_ops", name: "Ops leaders, UK" }],
  messages: [
    {
      body: "Hi, a quick question.",
      campaignId: "cmp_ops",
      direction: "out",
      id: "msg_1",
      leadId: "lead_1",
      senderId: "mbx_1",
      sentAt: "2026-11-10T09:15:00.000Z",
      stepIndex: 0,
      subject: "Question",
      threadId: "thr_1",
      variantKey: "A",
    },
    {
      body: "Yes, send me times.",
      campaignId: "cmp_ops",
      category: "positive",
      direction: "in",
      id: "msg_2",
      isAuto: false,
      leadId: "lead_1",
      senderId: "mbx_1",
      sentAt: "2026-11-11T08:40:00.000Z",
      threadId: "thr_1",
    },
  ],
  senders: [
    {
      address: "sales@example.com",
      campaignIds: ["cmp_ops"],
      dailyLimit: 40,
      health: { connected: true, dkim: true, dmarc: false, spf: true },
      id: "mbx_1",
      warmup: { enabled: true, score: 94 },
    },
    {
      campaignIds: ["cmp_li"],
      id: "li_1",
      linkedinUrl: "https://www.linkedin.com/in/example-sender-1",
    },
  ],
  stats: [
    // A row with no senderId and no stepIndex counts for its campaign and
    // for the workspace.
    {
      campaignId: "cmp_ops",
      counts: { bounced: 2, opened: 31, replied: 5, sent: 80 },
      date: "2026-11-10",
    },
    {
      campaignId: "cmp_ops",
      counts: { bounced: 1, opened: 12, replied: 3, sent: 40 },
      date: "2026-11-11",
    },
    // Per-step and per-sender numbers are rows of their own.
    {
      campaignId: "cmp_ops",
      counts: { replied: 6, sent: 90 },
      date: "2026-11-11",
      stepIndex: 0,
    },
    {
      campaignId: "cmp_ops",
      counts: { replied: 2, sent: 30 },
      date: "2026-11-11",
      stepIndex: 1,
    },
    {
      campaignId: "cmp_ops",
      counts: { replied: 8, sent: 120 },
      date: "2026-11-11",
      senderId: "mbx_1",
    },
  ],
  tags: [{ id: "tag_acme", name: "acme" }],
  webhooks: [
    {
      campaignId: "cmp_ops",
      events: ["reply_received"],
      id: "wh_1",
      url: "https://hooks.example.com/replies",
    },
  ],
};
Key Seed type Notes
campaigns MemoryCampaign steps (each a MemoryStep with MemoryVariants) become the sequence. settings is a MemorySettings. subsequences are MemorySubsequences.
senders MemorySender A sender with a linkedinUrl is a LinkedIn account, otherwise a mailbox. campaignIds attaches it to campaigns. health is a MemoryHealth.
leads MemoryLead campaignId puts the lead in a campaign, listIds on saved lists. engagement is partial: counts you leave out are null.
messages MemoryMessage direction and sentAt are required. An inbound message is also a reply; category and isAuto apply to inbound only.
stats MemoryStats One row per day. See below.
lists MemoryList size is counted from the leads whose listIds name the list.
blocklist MemoryBlockEntry kind defaults to email when the value has an @, else domain.
tags MemoryTag
webhooks MemoryWebhook Subscriptions that outbound.webhooks lists. url is required.

A variant with no key is lettered A, B, C by position. A step with no parentIndex follows the step before it.

Stats rows

A MemoryStats row is counts for one campaign on one day. Which reads a row feeds depends on its optional fields:

  • A row with neither senderId nor stepIndex counts for its campaign and for the workspace.
  • A row with stepIndex feeds per-step stats only. Add variantKey to make it the row of one variant of that step: a step read then returns one row per step and variant, with variantKey set, the way a vendor that splits step stats by variant does. A row without it is the step as a whole (variantKey: null).
  • A row with senderId feeds that sender’s stats only.

A stats read for a campaign that nothing in the seed names (no campaign, stats row, lead, message or sender) is NotFound. A campaign that is named and has no rows in the range returns no rows.

The adapter sums rows the way a vendor’s analytics endpoint would. It does not derive per-step or per-sender numbers from the campaign rows, so seed them when the code under test reads them. A count you leave out of counts is null, never 0.

import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";

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

const outbound = createOutbound({
  adapter: memory({ data: seed, pageSize: 1, workspaceId: "acme" }),
});

// Defaults: a campaign with only an id is named after it, active and email.
for await (const campaign of outbound.campaigns.listAll()) {
  console.log(campaign.ref, campaign.name, campaign.status, campaign.channel);
}

const campaign = outbound.campaign("cmp_ops");
const sequence = await campaign.sequence();

console.log(
  "steps:",
  sequence.steps.map((step) => step.variants.map((v) => v.variantKey).join(""))
);

const stats = await campaign.stats();
const [total] = stats.rows;

console.log("sent:", total?.sent, "replied:", total?.replied);

console.log("delivered:", total?.delivered);

const bySender = await outbound.sender("mbx_1").stats();

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

// A list's size is counted from the leads that name it.
const lists = await outbound.lists.list();

console.log("list size:", lists.items[0]?.size);

// A reply is an inbound message.
const replies = await outbound.replies.list({ include: ["body"] });

console.log("reply:", replies.items[0]?.category, replies.items[0]?.bodyText);

// No vendor is called, so nothing is spent and nothing is throttled.
console.log("requests:", stats.meta.requests, "evidence:", stats.meta.evidence);
memory:acme:cmp_ops Ops leaders active email
memory:acme:cmp_li cmp_li paused linkedin
steps: [ 'AB', 'A' ]
sent: 120 replied: 8
delivered: null
mailbox sent: 120
list size: 2
reply: positive Yes, send me times.
requests: 0 evidence: exact

The campaign’s two day rows sum to 120 sent and 8 replied. delivered was never seeded, so it is null.

Demo data

demoData() returns a small made-up data set: two campaigns, two mailboxes, three leads, three messages, two days of stats, and one list, tag, webhook and block list entry. Its dates are in September and October 2026. Use it when the numbers do not matter; write a seed when they do.

Limit it to what a vendor can do

Pass capabilities to turn single capabilities off or to weaken a filter from server to client. The adapter then behaves like a narrower vendor: gated calls are refused before they run, and a client filter is applied by the SDK and marked derived.

import { createOutbound, OutboundError } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";

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

// The same data behind an adapter that declares less, to exercise the code
// path a narrower vendor would take.
const outbound = createOutbound({
  adapter: memory({
    capabilities: {
      "campaigns.settings": false,
      "campaigns.status": "client",
      "stats.byStep": false,
    },
    data: seed,
    name: "narrowtool",
  }),
});

const attempt = async <Result>(label: string, read: () => Promise<Result>) => {
  try {
    await read();
    console.log(label, "- ok");
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(label, "-", error.code, "-", error.message);
  }
};

await attempt("sequence", () => outbound.campaign("cmp_ops").sequence());

await attempt("settings", () => outbound.campaign("cmp_ops").settings());

await attempt("step stats", () =>
  outbound.campaign("cmp_ops").stats({ scope: "step" })
);

// A `client` filter is applied by the SDK after fetching, and says so.
const paused = await outbound.campaigns.list({ status: "paused" });

console.log(
  "paused:",
  paused.items.map((campaign) => campaign.ref),
  paused.meta.evidence
);
sequence - ok
settings - Unsupported - Reading a campaign's settings is not available: the narrowtool adapter does not support it (campaigns.settings)
step stats - Unsupported - Reading stats for the step scope is not available: the narrowtool adapter does not support it (stats.byStep)
paused: [ 'narrowtool:default:cmp_li' ] derived

To get one vendor’s whole declaration, pass that adapter’s own capabilities: instantly({ apiKey: "not-used" }).capabilities. The guides on this site do that to show which calls each vendor’s adapter would refuse. It proves what the adapter refuses. It does not prove what the vendor’s API returns.

Where it differs from a vendor

import { createOutbound, OutboundError } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";

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

console.log(outbound.provider, outbound.workspaceId, outbound.maturity);

console.log("caveats:", outbound.caveats.length);

console.log("native:", outbound.capabilities.native);

console.log("events.parse:", outbound.capabilities["events.parse"]);

console.log("events.verify:", outbound.capabilities["events.verify"]);

console.log("estimate:", outbound.estimate({ action: "leads.list" }).basis);

const attempt = async <Result>(label: string, read: () => Promise<Result>) => {
  try {
    await read();
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(label, "-", error.code);
  }
};

await attempt("native", () => outbound.native.read("anything"));

await attempt("raw", () => outbound.raw.get("/campaigns"));

// The webhooks collection lists what the seed holds. It receives nothing.
const webhooks = await outbound.webhooks.list();

console.log("webhooks:", webhooks.items.length, webhooks.meta.requests);
memory default stable
caveats: 0
native: false
events.parse: false
events.verify: false
estimate: memory declares no cost for leads.list
native - Unsupported
raw - Unsupported
webhooks: 1 0
In-memory A vendor adapter
Requests None. meta.requests is 0 and meta.throttledMs is 0 on every result. Counted per result.
Rate limit A placeholder of 1,000,000 per second that never throttles. The vendor’s declared limits.
Failures The adapter raises only NotFound; the SDK adds Unsupported and Invalid for a gated or malformed call. Nothing times out, nothing returns a 429, nothing is retried. Every error code.
outbound.native Empty catalog. A read is Unsupported. The vendor’s catalog.
outbound.raw Every request is Unsupported: there is no API behind it. The vendor’s own API.
outbound.estimate No costs are declared, so requests is null. The adapter’s cost table.
Webhooks No parser and no verification. outbound.webhooks lists the seeded subscriptions and receives nothing. Parsed by outbound-sdk/webhooks.
Caveats and maturity No caveats; stable, because there is no vendor to verify against. See what is verified.
Evidence Stats rows are exact. exact or derived, by capability.
Data quirks Returns what you seeded, complete and consistent. See each adapter’s caveats.

Code that passes on the in-memory adapter has been shown to call the SDK correctly. It has not been shown to work against a vendor. Retries, rate limits and a vendor’s missing fields need an injected fetch or a live key to exercise.

Was this page helpful?