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
.envfile is not read for you: load it yourself, for example withnode --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
refof 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 asprovider:workspace. Workspace ... is not on this instance(NotFound) means no adapter was given thatworkspaceId. The label is the one you passed when you constructed the adapter, ordefault.
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"inpackage.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
nullwhere you expected 0: the vendor did not report it. See null is not zero. - A page has fewer records than
limit: go bycursor. See pagination. - A call hangs: the default
timeoutis 30 seconds per attempt with four retries. Setdeadline. See retries. - A
Providererror that saysreturned 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.maturityandoutbound.caveatssay how far the adapter was checked and what it assumes. Read them when a number looks wrong.