Read data only one vendor has, through the native catalog
List a vendor's own reads, call one with typed parameters or by a name learned at run time, reach it on a combined instance, and see what is refused, with requests and responses recorded offline.
The normalized reads cover what Instantly, Smartlead and HeyReach have in common. Some questions have no common form: “which lists is this person on in HeyReach?” is one. Each adapter lists those vendor-only reads in a catalog, and outbound.native calls them by name through the same engine as every other read, so they are rate limited and retried and their errors are normalized.
What comes back is the vendor’s own response. It is not normalized, not comparable across vendors and not masked. Every catalog entry is a read. Nothing here sends, pauses or edits, and a write through outbound.raw is refused.
Before you start
- Node 22 or later.
- The samples build a real HeyReach adapter and make no network call. The catalog is data on the adapter. The reads go through a
fetchthe sample supplies, which answers from fixture files in the repository. - For real data: a HeyReach, Instantly or Smartlead API key. A read-only key is enough.
What you get
Three reads: a typed native read, a native read by name, and a raw GET. The last block is every request the SDK made.
{
leadFullName: 'Alex Example',
totalCount: 3,
items: [
{
campaignId: 274079,
campaignName: 'Example campaign one',
creationTime: '2024-09-30T21:00:00.000000Z',
campaignStatus: 'FINISHED',
leadStatus: 'Finished'
},
{
campaignId: 274080,
campaignName: 'Example campaign two',
creationTime: '2024-10-05T09:30:00.000000Z',
campaignStatus: 'IN_PROGRESS',
leadStatus: 'InSequence'
}
]
}
{
complete: true,
evidence: 'exact',
fetchedAt: '2026-11-14T12:00:00.000Z',
provider: 'heyreach',
requests: 1,
stale: false,
throttledMs: 0,
workspaceId: 'default'
}
{
totalCount: 1,
items: [ { listId: 3457, listName: 'Example list one' } ]
}
{
id: 274079,
name: 'My First Campaign',
creationTime: '2025-12-01T08:06:15.463169Z',
linkedInUserListName: 'Example Lead List 1',
linkedInUserListId: 123456,
campaignAccountIds: [ 1101, 1103 ],
status: 'IN_PROGRESS',
progressStats: {
totalUsers: 9,
totalUsersInProgress: 8,
totalUsersPending: 0,
totalUsersFinished: 1,
totalUsersFailed: 0,
totalUsersManuallyStopped: 0,
totalUsersExcluded: 0
},
excludeInOtherCampaigns: false,
excludeHasOtherAccConversations: false,
excludeContactedFromSenderInOtherCampaign: false,
excludeListId: null,
organizationUnitId: 12345,
excludeFromLeadBlacklist: false,
excludeFromCompanyBlacklist: false,
startedAt: '2025-12-02T09:00:00Z'
}
[
'POST /api/public/campaign/GetCampaignsForLead {"email":"alex@example.com","limit":10}',
'POST /api/public/list/GetListsForLead {"email":"alex@example.com"}',
'GET /api/public/campaign/GetById?campaignId=274079'
]
datais the vendor’s payload, with the vendor’s field names:leadFullName,totalCount,campaignStatus.metais the SDK’s, the same as on any read:provider,requests,evidence.- One call is one request. The first answer says
totalCount: 3and holds two items. Nothing follows pages for you: pass the vendor’s own paging parameters, hereoffset, and call again.meta.completeistruebecause the one request succeeded. It does not know about the vendor’s paging. raw.getreturns the payload alone, with nometa.
List the catalog
outbound.native.catalog is static. Reading it makes no request, so you can print it before you have a working key.
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
// Reads HEYREACH_API_KEY. Reading the catalog makes no request.
const outbound = createOutbound({ adapter: heyreach() });
const { catalog } = outbound.native;
console.log(outbound.native.provider, "native:", outbound.capabilities.native);
console.log(Object.keys(catalog).length, "reads:");
console.log(Object.keys(catalog).join(", "));
// One entry: what it answers, the vendor's method and path, each parameter
// and where it goes, and the vendor's documentation page.
console.log(catalog.campaignsForLead);
heyreach native: true
25 reads:
campaignSequenceV2, campaignsForLead, checkApiKey, companiesFromList, conversationsV2, enrichEmailJob, isConnection, listById, listsForLead, myNetworkForSender, proxyCountries, statsByCampaign, webhookById, organizationLinkedInAccounts, organizationUser, organizationUsers, organizationWorkspaces, workspaceUsers, elevenLabsConnection, elevenLabsVoices, senderVoice, staticClip, staticClips, voiceUsage, voices
{
description: "The campaigns one lead is in, each with the campaign's status and the lead's status in it. Name the lead by email, LinkedIn id or profile URL.",
docs: 'https://docs.heyreach.io/hey-reach-api/campaigns/get-campaigns-for-lead',
method: 'POST',
params: {
email: {
description: "The lead's email address.",
in: 'body',
type: 'string'
},
linkedinId: {
description: "The lead's LinkedIn id, `linkedin_id` in lead payloads.",
in: 'body',
type: 'string'
},
profileUrl: {
description: "The lead's LinkedIn profile URL.",
in: 'body',
type: 'string'
},
limit: {
description: 'How many campaigns to return.',
in: 'body',
type: 'number'
},
offset: {
description: 'How many campaigns to skip.',
in: 'body',
type: 'number'
}
},
path: '/campaign/GetCampaignsForLead'
}
Each entry says what the read answers, the vendor’s method and path, each parameter with its type and where it goes (path, query or body), and the vendor’s documentation page. HeyReach reads some data with POST. The method is the vendor’s; the call is still a read.
Call a read
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { fixtureFetch, requests } from "./fixture-fetch.ts";
// `fetch` is injected so the sample needs no network. Leave it out in your code.
const outbound = createOutbound({ adapter: heyreach(), fetch: fixtureFetch });
// 1. A typed read. The parameters are checked by the compiler against the
// catalog entry: `limit: "10"` or an unknown key would not compile.
const campaigns = await outbound.native.reads.campaignsForLead({
email: "alex@example.com",
limit: 10,
});
console.log(campaigns.data);
console.log(campaigns.meta);
// 2. The same client by a name learned at run time, for example from a
// model or a config file. The parameters are checked when it runs.
const lists = await outbound.native.read("listsForLead", {
email: "alex@example.com",
});
console.log(lists.data);
// 3. Any other read path on the vendor's API, as the vendor documents it.
const campaign = await outbound.raw.get("/campaign/GetById", {
query: { campaignId: 274_079 },
});
console.log(campaign);
console.log(requests);
There are three ways in, from most to least checked:
outbound.native.reads.<name>(params)is typed from the catalog. A wrong parameter name, a missing required parameter or a string where a number belongs does not compile. Use it when you know the read while writing the code.outbound.native.read(name, params)takes the name as a string, for a name that arrives at run time from a config file or a model. The same checks run when it is called, and a bad call isInvalidbefore any request.outbound.raw.get(path, { query })andoutbound.raw.request(method, path, { query, body })take the vendor’s path as documented. Nothing about the parameters is checked. It is for an endpoint the catalog does not name. It still refuses anything that is not a known read.
The sample injects fetch so it can run offline. Leave that option out in your own code.
// A `fetch` that answers from the fixture files in the repository's
// fixtures/heyreach/ folder, so the samples run with no network. The fixtures
// follow HeyReach's documented field names; every value in them is made up.
// They are not responses captured from a live account.
import { readFileSync } from "node:fs";
const FIXTURES = new URL("../../../../fixtures/heyreach/", import.meta.url);
const ANSWERS = new Map([
["/api/public/campaign/GetById", "getCampaign.json"],
["/api/public/campaign/GetCampaignsForLead", "getCampaignsForLead.json"],
["/api/public/list/GetListsForLead", "getListsForLead.json"],
]);
/** Every request the SDK made, as `METHOD path body`. */
export const requests: string[] = [];
export const fixtureFetch: typeof fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : input);
const file = ANSWERS.get(url.pathname);
requests.push(
[init?.method ?? "GET", url.pathname + url.search, init?.body ?? ""]
.join(" ")
.trim()
);
return Promise.resolve(
file === undefined
? Response.json({ message: "no fixture" }, { status: 404 })
: new Response(readFileSync(new URL(file, FIXTURES), "utf-8"), {
headers: { "content-type": "application/json" },
})
);
};
On a combined instance
With several adapters behind one instance, outbound.native has no catalog: two vendors have two sets of names. Go through outbound.workspace(id), which is the same instance scoped to one adapter. It is typed as the general API, so use read(name) there, not reads.
import { createOutbound, OutboundError } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { heyreach } from "outbound-sdk/heyreach";
import { instantly } from "outbound-sdk/instantly";
import { fixtureFetch } from "./fixture-fetch.ts";
// Two vendors behind one instance. `workspaceId` is your own label.
const outbound = createOutbound({
adapter: combine([
instantly({ workspaceId: "acme-email" }),
heyreach({ workspaceId: "acme-linkedin" }),
]),
fetch: fixtureFetch,
});
// The combined instance has no catalog of its own: two vendors, two sets of names.
console.log("combined:", Object.keys(outbound.native.catalog).length, "reads");
try {
await outbound.native.read("listsForLead", { email: "alex@example.com" });
} catch (error) {
if (OutboundError.is(error)) {
console.log(`${error.code}: ${error.message}`);
}
}
// Each workspace keeps its own vendor's catalog.
for (const id of ["acme-email", "acme-linkedin"]) {
const { native } = outbound.workspace(id);
console.log(
`${id}: ${native.provider},`,
Object.keys(native.catalog).length,
"reads"
);
}
const lists = await outbound
.workspace("acme-linkedin")
.native.read("listsForLead", { email: "alex@example.com" });
console.log(lists.data, lists.meta.provider, lists.meta.workspaceId);
combined: 0 reads
Unsupported: native is not available on a combined instance: it belongs to one vendor. Use outbound.workspace("provider:workspace").native, for example "instantly:acme-email" or "heyreach:acme-linkedin"
acme-email: instantly, 105 reads
acme-linkedin: heyreach, 25 reads
{
totalCount: 1,
items: [ { listId: 3457, listName: 'Example list one' } ]
} heyreach acme-linkedin
Point it at your vendor
Remove the fetch option. heyreach() reads HEYREACH_API_KEY. For Instantly or Smartlead, swap the adapter and list its catalog: the read names are different for each vendor.
What each vendor can serve
Read from each real adapter’s declaration, with no request made.
import { createOutbound } from "outbound-sdk";
import type { Adapter, NativeCatalog, NativeRead } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { instantly } from "outbound-sdk/instantly";
import { smartlead } from "outbound-sdk/smartlead";
import { createTools } from "outbound-sdk/tools";
import { asVendor } from "../_shared/as-vendor.ts";
// Built from a real adapter to read its catalog. No request is made.
const describe = <Native extends NativeCatalog>(
adapter: Adapter<Native>
): void => {
const outbound = createOutbound({ adapter });
const reads: NativeRead[] = Object.values(outbound.native.catalog);
console.log(
`${outbound.provider}: native ${outbound.capabilities.native},`,
reads.length,
"reads,",
reads.filter((read) => read.method === "POST").length,
"of them sent as POST,",
"vendor_read",
createTools(outbound).some((tool) => tool.name === "vendor_read")
? "offered"
: "not offered"
);
};
describe(instantly());
describe(smartlead());
describe(heyreach());
// The in-memory stand-in used by the other guides declares what a vendor
// declares, but it has no catalog, so it cannot stand in for native reads.
const standIn = createOutbound({ adapter: asVendor("heyreach", {}) });
console.log(
`stand-in: native ${standIn.capabilities.native},`,
Object.keys(standIn.native.catalog).length,
"reads"
);
instantly: native true, 105 reads, 13 of them sent as POST, vendor_read offered
smartlead: native true, 78 reads, 16 of them sent as POST, vendor_read offered
heyreach: native true, 25 reads, 10 of them sent as POST, vendor_read offered
stand-in: native true, 0 reads
All three adapters declare a catalog. The last line is the in-memory stand-in the other guides run on: it copies a vendor’s declared capabilities but has no vendor API behind it, so it has no catalog and cannot stand in for native reads. That is why this guide runs on fixtures. The coverage pages account for every endpoint of each vendor.
What it costs in vendor requests
One native or raw call is one vendor request, counted against the same rate budget as normalized reads. A read can name its own rate lane where the vendor limits an endpoint separately. Listing the catalog costs nothing. To read every page of a vendor list you make one call per page, so set maxRequests on the instance or on the call when a loop drives it.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { memory } from "outbound-sdk/memory";
import { redact } from "outbound-sdk/redact";
import { fixtureFetch, requests } from "./fixture-fetch.ts";
const outbound = createOutbound({ adapter: heyreach(), fetch: fixtureFetch });
// Some of these calls throw before they return a promise, so each is made
// inside the function that catches.
const attempt = async (
label: string,
call: () => Promise<object | null>
): Promise<void> => {
try {
await call();
console.log(`${label}: ok`);
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${label}: ${error.code}: ${error.message}`);
}
};
// 1. A write is refused. The catalog holds no write, and `raw` checks the
// method and path against the vendor's known reads.
await attempt("pause a campaign", async () => ({
answer: await outbound.raw.request("POST", "/campaign/Pause", {
query: { campaignId: 274_079 },
}),
}));
await attempt("delete a webhook", async () => ({
answer: await outbound.raw.request("DELETE", "/webhooks/DeleteWebhook"),
}));
// 2. A read that would return a secret is refused too.
await attempt("read API keys", async () => ({
answer: await outbound.raw.get(
"/management/organizations/api-keys/workspaces/1"
),
}));
// 3. A name that is not in the catalog, a parameter the read does not take,
// a missing required parameter and a value of the wrong type.
await attempt("unknown read", () => outbound.native.read("pauseCampaign"));
await attempt("unknown parameter", () =>
outbound.native.read("listsForLead", { page: 2 })
);
await attempt("missing parameter", () =>
outbound.native.read("campaignSequenceV2")
);
await attempt("wrong type", () =>
outbound.native.read("campaignSequenceV2", { campaignId: "274079" })
);
// 4. With the redact plugin on, vendor reads are off: the payload is not
// normalized, so it cannot be masked.
const masked = createOutbound({
adapter: heyreach(),
fetch: fixtureFetch,
plugins: [redact()],
});
await attempt("with redact", () => masked.native.read("checkApiKey"));
// 5. The in-memory adapter has no vendor behind it, so no catalog and no raw.
const local = createOutbound({ adapter: memory() });
console.log("memory:", Object.keys(local.native.catalog).length, "reads");
await attempt("memory native", () => local.native.read("anything"));
await attempt("memory raw", async () => ({
answer: await local.raw.get("/anything"),
}));
// 6. The vendor answers 404: a normalized error, after one request.
await attempt("vendor 404", () => outbound.native.read("proxyCountries"));
console.log("requests made:", requests);
pause a campaign: Unsupported: POST /campaign/Pause is not a known read on heyreach. The SDK is read-only; pass allowWrites: true to createOutbound to permit it
delete a webhook: Unsupported: DELETE /webhooks/DeleteWebhook is not a known read on heyreach. The SDK is read-only; pass allowWrites: true to createOutbound to permit it
read API keys: Unsupported: GET /management/organizations/api-keys/workspaces/1 returns secrets (keys or passwords) on heyreach, so raw never reads it
unknown read: Invalid: heyreach has no native read "pauseCampaign". See outbound.native.catalog
unknown parameter: Invalid: listsForLead has no parameter "page". It takes: email, linkedinId, profileUrl, limit, offset
missing parameter: Invalid: campaignSequenceV2 needs "campaignId"
wrong type: Invalid: campaignSequenceV2: "campaignId" must be a number
with redact: Unsupported: Reading the vendor's own data is not available: the redact plugin turned it off (native)
memory: 0 reads
memory native: Unsupported: Reading the vendor's own data is not available: the memory adapter does not support it (native)
memory raw: Unsupported: GET /anything is not a known read on memory. The SDK is read-only; pass allowWrites: true to createOutbound to permit it
vendor 404: NotFound: heyreach responded 404: no fixture
requests made: [ 'GET /api/public/li_account/GetProxyCountries' ]
Only the last case reached the fixture fetch. Every refusal above it happened before a request.
| What happened | What you get | What to do |
|---|---|---|
A write, or any method and path that is not a known read, through raw |
Unsupported, before any request |
Nothing. The SDK is read-only. |
A GET whose response holds a secret, such as a workspace’s API keys |
Unsupported, before any request |
Nothing. The adapter refuses it on purpose. |
| A name that is not in the catalog | Invalid, before any request |
List outbound.native.catalog. |
| An unknown, missing or wrongly typed parameter | Invalid, naming the parameter |
Read the entry’s params. |
The redact plugin is on |
Unsupported: the plugin turned native off |
Use an instance without redact for this read. The payload cannot be masked. |
| A combined instance | Unsupported, pointing at workspace(id).native |
Scope to one workspace first. |
| The in-memory adapter | Unsupported. It has no catalog. |
Test native reads with an injected fetch, as here. |
| The vendor answers with an error | A normalized error: NotFound, Unauthorized, RateLimited and so on |
Handle it as for any other read. |
The refusal message for raw mentions allowWrites. That option on createOutbound turns the check off and takes the instance outside the read-only boundary. See raw before using it.
Limits
- A native response is the vendor’s. Field names, paging and meaning differ per vendor, and the vendor can change them. Code that reads it is not portable.
- It is not masked. Text inside it may be written by prospects. The
vendor_readtool wraps it as untrusted content for that reason. redactturns off bothoutbound.nativeandoutbound.raw: neither payload can be masked.- The responses shown are fixtures. No native read was run against a live account for this page. For what was run live, see what is verified.
- A native or raw call throws its refusal directly instead of returning a rejected promise, so
awaitthe call insidetry, as the samples do. A.catch()chained on the call does not see a refusal.