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

Troubleshooting

The errors a new user hits first, each with the exact message the SDK prints, what caused it and what to change.

Every failure is an OutboundError with a code and a message. Find the message below. Each one on this page was printed by a sample the test suite runs.

Where a sample needs a vendor to fail, it runs a real adapter with a made-up key against a stand-in server that answers with a status code and an empty body. The stand-in is shown on the retries page. No message here was copied from a live vendor.

Message starts with Code Section
instantly needs an API key Unauthorized No API key
instantly responded 401 Unauthorized The vendor rejected the key
... is not available: the ... adapter does not support it Unsupported The adapter cannot do that
... must be, ... must not be Invalid The input is wrong
... must be a ref (provider:workspace:id) Invalid A bare id on a combined instance
The call used its budget of RateLimited Rate limited
... is not available: the redact plugin turned it off Unsupported Native reads are off with redact
No "exports" main defined from Node require() fails

No API key

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

// The samples run with a placeholder key in the environment. Take it away
// to see what a missing key looks like.
delete process.env.INSTANTLY_API_KEY;

try {
  instantly();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}

// A variable that is set but blank counts as missing.
process.env.INSTANTLY_API_KEY = "   ";

try {
  instantly();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}
Unauthorized - instantly needs an API key: pass apiKey or set INSTANTLY_API_KEY
Unauthorized - instantly needs an API key: pass apiKey or set INSTANTLY_API_KEY

An adapter takes its key from the apiKey option, and otherwise from its environment variable: INSTANTLY_API_KEY, SMARTLEAD_API_KEY or HEYREACH_API_KEY. With neither, constructing the adapter throws. This is the one error that happens before createOutbound.

  • Check the variable is set in the process that runs your code, not only in your shell profile. A .env file is not read for you: load it yourself, for example with node --env-file=.env.
  • A variable that is set but empty or blank counts as missing.
  • The message names the variable for the adapter you constructed.

The vendor rejected the key

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

import { fakeVendor } from "../retries/fake-vendor.ts";

// A stand-in vendor that answers 401 with an empty body, as a vendor does
// for a key it does not accept.
const vendor = fakeVendor(401);

// Constructing with a wrong key works. Nothing is sent yet.
const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key" }),
  fetch: vendor.fetch,
});

try {
  await outbound.campaigns.list();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
    console.log("status", error.status, "| requests", vendor.count());
  }
}
Unauthorized - instantly responded 401
status 401 | requests 1

Constructing an instance sends nothing, so a wrong key is only found on the first read. A vendor’s 401 or 403 becomes Unauthorized. It is sent once and never retried.

  • Check the key was copied whole and belongs to the workspace you mean.
  • Check its scope, where the vendor has scopes. One read can then work while another is Unauthorized. A read-only key is enough for everything the SDK does.
  • When the vendor’s response has an error text, the message carries it after the status: instantly responded 401: .... The key itself never appears in a message.

The adapter cannot do that

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

import { fakeVendor } from "../retries/fake-vendor.ts";

const vendor = fakeVendor(200);
const outbound = createOutbound({ adapter: heyreach(), fetch: vendor.fetch });

const show = async <Result>(call: () => Promise<Result>): Promise<void> => {
  try {
    await call();
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(error.code, "-", error.message);
    }
  }
};

// HeyReach has no list of every lead: leads exist per campaign.
await show(() => outbound.leads.list());

await show(() => outbound.campaign("123").settings());

await show(() => outbound.campaign("123").stats({ scope: "step" }));

console.log("requests sent:", vendor.count());

// Ask before calling.
console.log(
  outbound.capabilities["leads.list"],
  outbound.capabilities["leads.byCampaign"]
);
Unsupported - Listing leads is not available: the heyreach adapter does not support it (leads.list)
Unsupported - Reading a campaign's settings is not available: the heyreach adapter does not support it (campaigns.settings)
Unsupported - Reading stats for the step scope is not available: the heyreach adapter does not support it (stats.byStep)
requests sent: 0
false true

The vendors do not offer the same reads. A read the adapter cannot serve throws Unsupported before any request, and the message ends with the capability key in brackets.

  • Look the key up on the capabilities page. It often names a nearby read that does work: HeyReach has no list of every lead, and does list the leads of one campaign.
  • Check outbound.capabilities[key] before the call in code that runs on more than one vendor.
  • The adapters page has the table of what each vendor declares.
  • If the message says a plugin turned it off, see below.

The input is wrong

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

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

const show = async <Result>(call: () => Promise<Result>): Promise<void> => {
  try {
    await call();
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(error.code, "-", error.message);
    }
  }
};

await show(() => outbound.stats({ from: "11/01/2026" }));

await show(() => outbound.stats({ from: "2026-11-10", to: "2026-11-01" }));

await show(() => outbound.stats({ scope: "campaign" }));

await show(() => outbound.replies.list({ since: "yesterday" }));

await show(() => outbound.leads.list({ limit: 2.5 }));

await show(() => outbound.campaigns.get(" "));

await show(() =>
  outbound.leads.list({ campaignId: "cmp_founders", listId: "lst_founders" })
);

// A ref from another workspace.
await show(() => outbound.campaigns.get("memory:other-client:cmp_founders"));

// A cursor from another listing.
const page = await outbound.leads.list({ limit: 1 });

await show(() => outbound.replies.list({ cursor: page.cursor }));
Invalid - from must be a date in YYYY-MM-DD form, got "11/01/2026"
Invalid - from must not be after to
Invalid - stats for the campaign scope need a scopeId
Invalid - since must be an ISO 8601 date or timestamp, got "yesterday"
Invalid - limit must be a positive integer, got 2.5
Invalid - Campaign id must not be empty
Invalid - Leads can be listed by campaign or by list, not both at once
Invalid - memory:other-client:cmp_founders belongs to workspace other-client, not default
Invalid - The cursor is not valid for replies.list (it belongs to leads.list)

Invalid from the SDK means the call was refused before any request. The message says which value and what it must be.

Message Cause
from must be a date in YYYY-MM-DD form from and to on stats are calendar days. A timestamp is refused.
from must not be after to The range is backwards.
stats for the campaign scope need a scopeId outbound.stats({ scope }) needs scopeId. Or use outbound.campaign(id).stats(), which fills it in.
since must be an ISO 8601 date or timestamp since and until on listings take a date or a timestamp, not words.
limit must be a positive integer
Campaign id must not be empty An empty or blank id, often an undefined turned into a string further up.
Leads can be listed by campaign or by list, not both at once Pick campaignId or listId.
... belongs to workspace other-client, not default A ref from one workspace was passed to an instance for another.
The cursor is not valid for replies.list (it belongs to leads.list) A cursor only continues the listing it came from. See pagination.

TypeScript catches most of these before you run. They show up at run time when the value comes from user input, a config file or an agent.

A vendor can also answer 400 or 422, which becomes Invalid with the message <vendor> responded 400. That means the request reached the vendor and it refused it. It is not retried.

A bare id on a combined instance

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

// Two clients behind one instance. Both have a campaign with the same id.
const outbound = createOutbound({
  adapter: combine([
    memory({ data: demoData(), workspaceId: "acme" }),
    memory({ data: demoData(), workspaceId: "globex" }),
  ]),
});

try {
  await outbound.campaigns.get("cmp_founders");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}

// Use the ref a record carries.
const { items } = await outbound.campaigns.list();

console.log(items.map((campaign) => campaign.ref));

const [first] = items;
const campaign = await outbound.campaigns.get(first?.ref ?? "");

console.log(campaign.name, "from", campaign.workspaceId);

// Or scope to one workspace, where a bare id is enough.
const globex = await outbound.workspace("globex").campaigns.get("cmp_founders");

console.log(globex.ref);

try {
  outbound.workspace("initech");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}
Invalid - Campaign "cmp_founders" must be a ref (provider:workspace:id) of an adapter on this combined instance
[ 'memory:acme:cmp_founders', 'memory:acme:cmp_agencies' ]
Q4 SaaS founders from acme
memory:globex:cmp_founders
NotFound - Workspace initech is not on this instance

An instance made with combine has several adapters behind it. A vendor’s own id does not say which one it belongs to, and two of them can hold the same id, as the two workspaces above do.

  • Pass the ref of a record. Every record carries one, and it names the provider and the workspace. See refs.
  • Or scope first with outbound.workspace(id). On the scoped instance a bare id works, as long as the label belongs to one adapter. When two adapters share a label, name one as provider:workspace.
  • Workspace ... is not on this instance (NotFound) means no adapter was given that workspaceId. The label is the one you passed when you constructed the adapter, or default.

Rate limited

import {
  createOutbound,
  MemoryRateLimitStore,
  OutboundError,
} from "outbound-sdk";
import type { OutboundOptions } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "../retries/fake-vendor.ts";
import { instantClock } from "../retries/instant-clock.ts";

const attempt = async (
  label: string,
  options: Partial<OutboundOptions>
): Promise<void> => {
  // A stand-in vendor that answers 429 and asks for 60 seconds.
  const vendor = fakeVendor({ headers: { "retry-after": "60" }, status: 429 });

  const outbound = createOutbound({
    adapter: instantly({ apiKey: "example-key" }),
    fetch: vendor.fetch,
    // A budget of its own for each run, so one run does not slow the next.
    rateLimitStore: new MemoryRateLimitStore(),
    runtime: instantClock(),
    ...options,
  });

  try {
    await outbound.raw.get("/campaigns");
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(`${label}: ${error.code} - ${error.message}`);
      console.log(
        `  requests ${vendor.count()}, timedOut ${error.timedOut}, retryAfterMs ${error.retryAfterMs}`
      );
    }
  }
};

// The wait the vendor asked for does not fit in the deadline.
await attempt("deadline", { deadline: 30_000 });

// The call may spend three requests. The fourth is never sent.
await attempt("maxRequests", { maxRequests: 3 });

// Neither is set: the SDK waits and retries four times, then gives up.
await attempt("retries used up", {});
deadline: RateLimited - instantly responded 429
  requests 1, timedOut true, retryAfterMs 60000
maxRequests: RateLimited - The call used its budget of 3 request(s) to instantly
  requests 3, timedOut false, retryAfterMs undefined
retries used up: RateLimited - instantly responded 429
  requests 5, timedOut false, retryAfterMs 60000

The SDK waits out a vendor’s 429 and retries on its own, so RateLimited reaches you only when one of three limits stops that. The message and the fields say which.

What you see What stopped it What to do
<vendor> responded 429, timedOut: true The wait the vendor asked for would end after the call’s deadline. Raise deadline, or try again after retryAfterMs.
The call used its budget of N request(s) maxRequests. The next request was not sent. Raise maxRequests, or ask for less in one call: a smaller max, fewer campaigns.
<vendor> responded 429, timedOut: false The retries ran out and the vendor was still limiting. Try again after retryAfterMs. Something else may be using the same key.
<vendor> rate budget is full and the deadline would pass before it frees up The SDK’s own budget for the key was full, and waiting for room would pass the deadline. No request was sent. Raise deadline, or spread the work out.

The last message is not in the output above. It comes from the SDK’s own pacing, which keeps requests under the vendor’s published limit before the vendor has to refuse any. If you see 429s often, the key is probably shared with other tools or other processes: see rate limits for share and for a budget shared between processes.

RateLimited is never permanent. The same call can work later.

Native reads are off with redact

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

import { fakeVendor } from "../retries/fake-vendor.ts";

const vendor = fakeVendor(200);

const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key" }),
  fetch: vendor.fetch,
  plugins: [redact()],
});

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

try {
  await outbound.native.read("auditLogs");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}

console.log("requests sent:", vendor.count());
native: false
Unsupported - Reading the vendor's own data is not available: the redact plugin turned it off (native)
requests sent: 0

A native read returns the vendor’s payload untouched. The redact plugin cannot mask a payload whose shape it does not know, so it turns the native capability off, and the read is refused before any request. The same happens to include: ["body"] when redact is masking message text.

That is on purpose: the plugin refuses what it cannot protect. If you need a native read, make it on a second instance without redact, and handle what comes back as unmasked personal data.

outbound.raw returns the same untouched payload, so it is refused on an instance with redact too, with a message that names the plugin.

require() fails

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in /your/project/node_modules/outbound-sdk/package.json

The package is ESM only. A CommonJS file that calls require("outbound-sdk") gets this error from Node, as does a TypeScript project that compiles to CommonJS. This message was produced on Node 24 by requiring the package from a .cjs file. It is not one of the samples the test suite runs.

Either:

  • Make the project ESM: "type": "module" in package.json, and in TypeScript "module": "NodeNext".
  • Or load it with a dynamic import, await import("outbound-sdk"), which works from a CommonJS file. That was checked the same way.

The same error for a subpath, Package subpath './something' is not defined by "exports", means that subpath does not exist. The installation page lists the ones that do.

Something else

  • Cannot find package 'outbound-sdk': the package is not on npm yet. See installation.
  • A number is null where you expected 0: the vendor did not report it. See null is not zero.
  • A page has fewer records than limit: go by cursor. See pagination.
  • A call hangs: the default timeout is 30 seconds per attempt with four retries. Set deadline. See retries.
  • A Provider error that says returned an invalid record: the vendor sent something the adapter did not expect and the record failed its schema. That is a bug in the adapter, or a change at the vendor. Check what is verified for the adapter, and report it with the message.
  • outbound.maturity and outbound.caveats say how far the adapter was checked and what it assumes. Read them when a number looks wrong.

Was this page helpful?