---
title: Test code that reads outbound data without a vendor key
description: Tests for your own code that run on the in-memory adapter with a seed you write, covering unsupported calls, vendor failures, paging and recorded answers, plus what the in-memory adapter does not tell you.
sidebar:
  label: Test without a key
related:
  - /docs/adapters/memory
  - /docs/plugins/fixtures
  - /docs/capabilities
  - /docs/api/errors
  - /docs/samples
---

Code that reads campaigns and replies should have tests that run in CI with no key, no network and no vendor account. The in-memory adapter is a complete adapter over data you hand it. Your code runs against it unchanged, because it takes an `Outbound` instance and does not know which adapter is behind it.

This guide tests one small function five ways: with a seed, with capabilities turned off, with injected failures, with small pages, and against recorded answers. Then it says where the in-memory adapter differs from a vendor, because a green run here proves your code and nothing about the vendor.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## Before you start

- Node 22 or later.
- No key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- The samples are plain scripts that assert with `node:assert/strict` and print a line per check, because that is how this site runs them. In your project, put the same assertions inside `test()` from `node:test`, or `it()` in Vitest or Jest. Nothing here depends on a test runner.

## What you get

```text title="Output"
ok - only active campaigns are summarised
ok - counts are added up over the days seeded
ok - no results is null, not 0
ok - refs are provider:workspace:id
```

## The code under test

One line per active campaign: sends, reply rate and how many replies the vendor labelled positive.

```ts title="summary.ts"
// The code under test: one line per active campaign.
import type { Outbound } from "outbound-sdk";

export interface CampaignLine {
  readonly name: string;
  readonly sent: number | null;
  readonly replyRate: number | null;
  /** `null` when the adapter cannot say which replies are positive. */
  readonly positiveReplies: number | null;
}

export const campaignSummary = async (
  outbound: Outbound
): Promise<CampaignLine[]> => {
  const lines: CampaignLine[] = [];

  for await (const campaign of outbound.campaigns.listAll({
    status: "active",
  })) {
    const handle = outbound.campaign(campaign.ref);
    const stats = await handle.stats();
    const [total] = stats.rows;
    const sent = total?.sent ?? null;
    const replied = total?.replied ?? null;

    const positive =
      outbound.capabilities["replies.category"] === false
        ? null
        : await handle.replies.listAll({ category: "positive" }).toArray();

    lines.push({
      name: campaign.name,
      positiveReplies: positive === null ? null : positive.length,
      replyRate:
        sent === null || replied === null || sent === 0 ? null : replied / sent,
      sent,
    });
  }

  return lines;
};
```

It takes the instance as an argument. That is the whole trick: production passes one built on `instantly()`, a test passes one built on `memory()`.

## A seed of your own

Only `id` is required on every seed record, and every other field has a default. State what the test is about and leave the rest out.

```ts title="seed.ts"
// Your own seed: only an id is required, so state what the test is about and
// nothing else. Three campaigns: two active, one paused, one with no results.
import type { ReplyCategory } from "outbound-sdk";
import type { MemoryData, MemoryMessage } from "outbound-sdk/memory";

const reply = (
  id: string,
  campaignId: string,
  category: ReplyCategory
): MemoryMessage => ({
  campaignId,
  category,
  direction: "in",
  id,
  sentAt: "2026-11-12T10:00:00Z",
});

export const seed: MemoryData = {
  campaigns: [
    { id: "cmp_founders", name: "SaaS founders" },
    { id: "cmp_agencies", name: "Agency owners" },
    { id: "cmp_old", name: "Last quarter", status: "paused" },
  ],
  leads: Array.from({ length: 5 }, (_, index) => ({
    campaignId: "cmp_founders",
    company: "Northwind",
    email: `lead${index + 1}@northwind.example.com`,
    firstName: "Dana",
    id: `lead_${index + 1}`,
  })),
  messages: [
    reply("msg_1", "cmp_founders", "positive"),
    reply("msg_2", "cmp_founders", "positive"),
    reply("msg_3", "cmp_founders", "negative"),
  ],
  stats: [
    {
      campaignId: "cmp_founders",
      counts: { replied: 3, sent: 120 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_founders",
      counts: { replied: 3, sent: 80 },
      date: "2026-11-13",
    },
    {
      campaignId: "cmp_old",
      counts: { replied: 9, sent: 900 },
      date: "2026-08-01",
    },
  ],
};
```

```ts title="run.ts"
import assert from "node:assert/strict";

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

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

const outbound = createOutbound({ adapter: memory({ data: seed }) });
const [founders, agencies, ...rest] = await campaignSummary(outbound);

// The paused campaign is left out.
assert.equal(rest.length, 0);

console.log("ok - only active campaigns are summarised");

// 6 replies of 200 sends, 2 of them positive.
assert.deepEqual(founders, {
  name: "SaaS founders",
  positiveReplies: 2,
  replyRate: 0.03,
  sent: 200,
});

console.log("ok - counts are added up over the days seeded");

// A campaign with no results has no row at all: the summary must not turn
// that into a zero.
assert.deepEqual(agencies, {
  name: "Agency owners",
  positiveReplies: 0,
  replyRate: null,
  sent: null,
});

console.log("ok - no results is null, not 0");

// Every record carries where it came from.
const leads = await outbound.leads.list();
const [lead] = leads.items;

assert.equal(lead?.ref, "memory:default:lead_1");

assert.equal(lead?.campaignRef, "memory:default:cmp_founders");

console.log("ok - refs are provider:workspace:id");
```

That is the script that printed the four lines above. Three things worth asserting in your own tests:

- **A missing count is `null`.** The campaign with no stats rows has no total, and the summary must not print `0` for it.
- **Refs, not ids.** Every record carries `provider:workspace:id`. Assert on `ref` when the code will ever run on more than one account.
- **Use dates near now.** Code that asks for "the last 7 days" reads the clock. Seed dates relative to it, or pass your own clock with `createOutbound({ runtime: { now } })`.

`demoData()` from `outbound-sdk/memory` is a ready-made seed for trying things out. For a test, write the seed in the test, so the expected numbers are visible next to the assertions.

## Limit capabilities to exercise `Unsupported`

The in-memory adapter declares every capability. No vendor does. Pass `capabilities` to turn one off, and the instance refuses the calls that need it, before they reach the adapter, with the same error a vendor's adapter gives.

To run a test as a particular vendor, limit the in-memory adapter to what that vendor's adapter declares. [The helper](/docs/samples) is a few lines, and every guide on this site uses it:

```ts title="vendors.ts"
import assert from "node:assert/strict";

import { createOutbound } from "outbound-sdk";

import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import type { Vendor } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";
import { campaignSummary } from "./summary.ts";

// The same test once per vendor, on the in-memory adapter limited to what
// that vendor's adapter declares.
const testAs = async (vendor: Vendor): Promise<string> => {
  const outbound = createOutbound({ adapter: asVendor(vendor, seed) });
  const [founders] = await campaignSummary(outbound);

  assert.equal(founders?.sent, 200);

  // HeyReach does not label replies, so the summary cannot count positives.
  assert.equal(founders?.positiveReplies, vendor === "heyreach" ? null : 2);

  return `ok - ${vendor}: sent ${founders?.sent}, positive replies ${founders?.positiveReplies}`;
};

const lines = await Promise.all(VENDORS.map(testAs));

console.log(lines.join("\n"));
```

```text title="Output"
ok - instantly: sent 200, positive replies 2
ok - smartlead: sent 200, positive replies 2
ok - heyreach: sent 200, positive replies null
```

This shows which calls a vendor's adapter would refuse. It does not show what that vendor's API returns. HeyReach does not label replies, so the summary reports `null` for positive replies there and does not call the filter.

## Inject failures with a plugin

The in-memory adapter has no failure switch. A plugin's `wrap` sits between your call and the adapter, and can throw the error a vendor's adapter would.

```ts title="failing.ts"
import { OutboundError } from "outbound-sdk";
import type { OutboundErrorCode, OutboundPlugin } from "outbound-sdk";

/**
 * Fails the first `times` operations the way a vendor would, then lets the
 * rest through. The in-memory adapter has no failure switch of its own.
 */
export const failing = (
  code: OutboundErrorCode,
  times = 1,
  retryAfterMs?: number
): OutboundPlugin => {
  let left = times;

  return {
    name: "failing",
    wrap: (operation, next, info) => {
      if (left > 0) {
        left -= 1;

        throw new OutboundError(code, `injected ${code}`, {
          provider: info.provider,
          retryAfterMs,
        });
      }

      return next(operation);
    },
  };
};
```

```ts title="failures.ts"
import assert from "node:assert/strict";

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

import { failing } from "./failing.ts";
import { seed } from "./seed.ts";
import { campaignSummary } from "./summary.ts";

// 1. Turn a capability off and the call is refused before it reaches the
//    adapter, as it would be on a vendor that lacks it.
const noStats = createOutbound({
  adapter: memory({ capabilities: { "stats.campaign": false }, data: seed }),
});

await assert.rejects(
  campaignSummary(noStats),
  (error) => OutboundError.is(error) && error.code === "Unsupported"
);

console.log("ok - no campaign stats: Unsupported");

// 2. A capability the code checks for itself: the fallback branch runs.
const noCategories = createOutbound({
  adapter: memory({ capabilities: { "replies.category": false }, data: seed }),
});

const [line] = await campaignSummary(noCategories);

assert.equal(line?.positiveReplies, null);

assert.equal(line?.sent, 200);

console.log("ok - no reply categories: positiveReplies is null");

// 3. A vendor error reaches the caller as an OutboundError with its code.
const down = createOutbound({
  adapter: memory({ data: seed }),
  plugins: [failing("Provider")],
});

await assert.rejects(campaignSummary(down), (error) => {
  assert.ok(OutboundError.is(error));
  assert.equal(error.code, "Provider");
  assert.equal(error.permanent, false, "worth trying again");

  return true;
});

// The plugin failed one operation, so the second attempt goes through.
const secondTry = await campaignSummary(down);

assert.equal(secondTry.length, 2);

console.log("ok - Provider once, then the retry succeeds");

// 4. A rate limit carries how long the vendor asked you to wait.
const limited = createOutbound({
  adapter: memory({ data: seed }),
  plugins: [failing("RateLimited", 1, 30_000)],
});

await assert.rejects(limited.campaigns.list(), (error) => {
  assert.ok(OutboundError.is(error));
  assert.equal(error.retryAfterMs, 30_000);

  return true;
});

console.log("ok - RateLimited carries retryAfterMs");

// 5. A rejected key is permanent: retrying cannot help.
const rejected = createOutbound({
  adapter: memory({ data: seed }),
  plugins: [failing("Unauthorized", Number.POSITIVE_INFINITY)],
});

await assert.rejects(rejected.campaigns.list(), (error) => {
  assert.ok(OutboundError.is(error));
  assert.equal(error.permanent, true);

  return true;
});

console.log("ok - Unauthorized is permanent");

// 6. A bulk lookup reports a missing record as data. It does not throw.
const outbound = createOutbound({ adapter: memory({ data: seed }) });
const bulk = await outbound.campaigns.getMany(["cmp_founders", "cmp_nope"]);

assert.deepEqual(
  bulk.results.map((result) => result.value.name),
  ["SaaS founders"]
);

assert.deepEqual(
  bulk.errors.map((failure) => [failure.id, failure.error.code]),
  [["cmp_nope", "NotFound"]]
);

assert.equal(await outbound.campaigns.exists("cmp_nope"), false);

console.log("ok - getMany returns the missing record as an error entry");
```

```text title="Output"
ok - no campaign stats: Unsupported
ok - no reply categories: positiveReplies is null
ok - Provider once, then the retry succeeds
ok - RateLimited carries retryAfterMs
ok - Unauthorized is permanent
ok - getMany returns the missing record as an error entry
```

| What happened | What you get | What to assert |
| --- | --- | --- |
| A capability is off | `Unsupported`, before the adapter is called | That your code checks `outbound.capabilities` first, or lets the error through on purpose. |
| The vendor is down | `Provider`, with `permanent: false` | That your code retries later or reports it. It should not treat it as "no data". |
| The vendor is rate limiting | `RateLimited`, with `retryAfterMs` when the vendor gave one | That your code waits at least that long. |
| The key is rejected | `Unauthorized`, with `permanent: true` | That your code stops and does not retry. |
| One record of a bulk lookup is missing | An entry in `errors`. `getMany` does not throw. | That your code reads `errors` as well as `results`. |

An error thrown by a plugin reaches your code at once. The retries and waits a real adapter does for a 503 or a 429 happen in the request engine, below the plugins, and the in-memory adapter never gets there. To test those, use a real adapter with `fetch` replaced, as shown [further down](#the-adapter-itself-without-a-key).

## Paging with a small page size

The default page is 50 records. A five-record seed then fits on one page, and code that forgets to follow the cursor passes its tests. Set `pageSize` to 2.

```ts title="pagination.ts"
import assert from "node:assert/strict";

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

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

// Two records a page, so the five seeded leads are three pages. The default
// page size is 50, which would hide every paging bug in a small seed.
const outbound = createOutbound({
  adapter: memory({ data: seed, pageSize: 2 }),
});

const first = await outbound.leads.list();

assert.equal(first.items.length, 2);

assert.equal(first.complete, false);

assert.ok(first.cursor !== undefined);

console.log("ok - a page says it is not the last and gives a cursor");

const second = await outbound.leads.list({ cursor: first.cursor });
const third = await outbound.leads.list({ cursor: second.cursor });

assert.deepEqual(
  [...second.items, ...third.items].map((lead) => lead.sourceId),
  ["lead_3", "lead_4", "lead_5"]
);

assert.equal(third.complete, true);

assert.equal(third.cursor, undefined);

console.log("ok - the cursor continues where the page ended");

const everyLead = await outbound.leads.listAll().toArray();

assert.equal(everyLead.length, 5);

console.log("ok - listAll walks every page");

const capped = outbound.leads.listAll({ max: 3 });
const firstThree = await capped.toArray();

assert.equal(firstThree.length, 3);

assert.equal(capped.truncated, true);

console.log("ok - max stops the walk and says records were left");

// A cursor belongs to the listing that issued it.
await assert.rejects(
  outbound.campaigns.list({ cursor: first.cursor }),
  (error) => OutboundError.is(error) && error.code === "Invalid"
);

console.log("ok - a leads cursor is refused by campaigns");
```

```text title="Output"
ok - a page says it is not the last and gives a cursor
ok - the cursor continues where the page ended
ok - listAll walks every page
ok - max stops the walk and says records were left
ok - a leads cursor is refused by campaigns
```

## Record once, replay in every test

The `fixtures` plugin saves each answer to a file and serves it back. `record` calls the adapter and saves, `replay` answers from disk and never calls the adapter, and `auto` (the default) replays when a file exists and records when it does not. Personal data is replaced with numbered placeholders before anything is saved.

This sample records against the in-memory adapter into the system's temporary folder, then replays with an adapter that holds no data at all.

```ts title="record-replay.ts"
import assert from "node:assert/strict";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";

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

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

const dir = await mkdtemp(path.join(tmpdir(), "outbound-fixtures-"));

// Record. Against a real vendor this is the one run that needs a key.
const recording = createOutbound({
  adapter: memory({ data: seed }),
  plugins: [fixtures({ dir, mode: "record" })],
});

const recorded = await recording.leads.list();

await recording.campaigns.list();

const files = await readdir(dir);

console.log("files:", files.toSorted());

// What was saved, and what the recording run itself got back, is scrubbed.
assert.equal(recorded.items[0]?.email, "person1@example.com");

assert.equal(recorded.items[0]?.company, "Company 1");

console.log("ok - personal data is replaced before it is saved");

// Replay. The adapter here is empty: every answer comes from the files.
const replaying = createOutbound({
  adapter: memory(),
  plugins: [fixtures({ dir, mode: "replay" })],
});

const replayed = await replaying.leads.list();

assert.deepEqual(replayed.items, recorded.items);

assert.equal(replayed.items.length, 5);

console.log("ok - replay answers from disk without the adapter");

// A read that was never recorded fails. It does not fall through to the
// vendor, and different options are a different read.
await assert.rejects(
  replaying.leads.list({ campaignId: "cmp_founders" }),
  (error) => OutboundError.is(error) && error.code === "NotFound"
);

console.log("ok - an unrecorded read is NotFound");

await rm(dir, { recursive: true });
```

```text title="Output"
files: [
  'memory.default.campaigns.list.1d2c822351e4.json',
  'memory.default.leads.list.5489d1f8c1a0.json'
]
ok - personal data is replaced before it is saved
ok - replay answers from disk without the adapter
ok - an unrecorded read is NotFound
```

What to know before relying on it:

- **A file is one operation.** Its name is the provider, the workspace, the operation and a hash of the workspace and the operation's exact options. A read with another filter, page size or cursor has no file and fails with `NotFound` in `replay`.
- **The recording run gets the scrubbed data too.** Assert on the placeholders (`person1@example.com`, `Company 1`), not on the real values. The same real value always gets the same placeholder from one instance of the plugin. Counts and stats are kept as they are.
- **List it last when recording**, so what is saved is the adapter's answer and not another plugin's view of it.
- **A recording made with a real key is a snapshot** of that account on that day. It is still the only one of these techniques that holds real vendor responses, as mapped by the adapter. Commit the files only after reading them: scrubbing covers the fields the SDK knows to be personal.

## The adapter itself, without a key

Everything above replaces the adapter. To run a real adapter's own code (its mapping, its request count) with no key, give `createOutbound` a `fetch` of your own. This one answers from Instantly's documented payloads in the repository's `fixtures/instantly/` folder. They are built from Instantly's published schema and are not live responses.

```ts title="http.ts"
// A real adapter with a made-up key and no network. `fetch` is replaced with
// one that answers from Instantly's documented payloads in the repository's
// fixtures/instantly/ folder. Those are not live responses.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

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

const FIXTURES = new URL("../../../../fixtures/instantly/", import.meta.url);

const DOCUMENTED = new Map([
  ["/api/v2/campaigns", "listCampaigns.page2.json"],
  ["/api/v2/custom-tag-mappings", "listCustomTagMappings.json"],
  ["/api/v2/custom-tags", "listCustomTags.page2.json"],
]);

const paths: string[] = [];

const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key", workspaceId: "acme" }),
  fetch: (input, init) => {
    const { pathname } = new URL(new Request(input, init).url);
    const file = DOCUMENTED.get(pathname);

    paths.push(pathname);

    return Promise.resolve(
      file === undefined
        ? new Response("{}", { status: 404 })
        : new Response(readFileSync(new URL(file, FIXTURES), "utf-8"))
    );
  },
});

const page = await outbound.campaigns.list();

// The adapter's own mapping ran: Instantly's numeric status became a word.
assert.equal(page.items[0]?.name, "Third Campaign");

assert.equal(page.items[0]?.status, "paused");

console.log("ok - the documented payload maps to a Campaign");

// Unlike the in-memory adapter, this one counts requests.
assert.equal(page.meta.requests, paths.length);

console.log(`ok - ${page.meta.requests} requests:`, paths.join(", "));
```

```text title="Output"
ok - the documented payload maps to a Campaign
ok - 3 requests: /api/v2/campaigns, /api/v2/custom-tag-mappings, /api/v2/custom-tags
```

A `fetch` like this can also answer 503 or 429 to exercise retries. Those wait on real timers unless you replace them too, with the `runtime` option.

## Where memory differs from a real vendor

| On the in-memory adapter | On a vendor's adapter |
| --- | --- |
| Every capability is on unless you turn it off. | Each vendor lacks some. Run your tests as each vendor you support. |
| No HTTP request is made. `meta.requests` is `0` and `onRequest` hooks never fire. | Each read costs requests, some more than one per page. |
| Nothing is rate limited, throttled or retried. | Calls wait for the rate budget and retry failures. `deadline` and `maxRequests` matter. |
| Filters are exact: status, date and category do what they say. | Some filters are applied by the SDK after fetching, and some are refused. |
| Evidence is always `exact`. | An answer can be `derived` or `sampled`, and a count the vendor does not report is `null`. |
| A page is `pageSize` records, by offset. | Page sizes and cursors are the vendor's. A page can come back short. |
| Ids are whatever you seeded. | Ids are the vendor's: UUIDs, numbers, or a LinkedIn profile URL for a HeyReach lead. |
| `raw` is `null` and there are no caveats. | `raw` holds the vendor's payload, and `outbound.caveats` lists what the adapter assumes. |
| It never fails by itself. | Keys expire, records disappear, vendors go down. |

A test on the in-memory adapter proves that your code is correct for the data it was given. Whether a vendor returns that data is a separate question, answered by [what is verified](/docs/status) and by running your code once against a real account.

## Limits

- The in-memory adapter holds the data you gave it when it was created. To test "the data changed", create a second adapter with the second seed.
- Fixture files are tied to the exact operation. Changing a filter in your code means recording again.
- Scrubbed recordings cannot test code that depends on real names, domains or message text.
- Nothing on this page checks a vendor's behaviour. Instantly and HeyReach are `beta` and Smartlead is `unverified`: see [what is verified](/docs/status).
