---
title: In-memory adapter
description: 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.
sidebar:
  label: In-memory
related:
  - /docs/adapters
  - /docs/capabilities
  - /guides/test-without-a-key
---

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

```ts title="seed.ts"
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 `MemoryVariant`s) become the sequence. `settings` is a `MemorySettings`. `subsequences` are `MemorySubsequence`s. |
| `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`.

```ts title="run.ts"
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);
```

```text title="Output"
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`.

```ts title="limited.ts"
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
);
```

```text title="Output"
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

```ts title="differences.ts"
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);
```

```text title="Output"
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](/docs/api/errors). |
| `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](/docs/status). |
| 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.
