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
senderIdnorstepIndexcounts for its campaign and for the workspace. - A row with
stepIndexfeeds per-step stats only. AddvariantKeyto make it the row of one variant of that step: a step read then returns one row per step and variant, withvariantKeyset, 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
senderIdfeeds 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.