Native catalog
Call any read a vendor offers that has no normalized form, by name and with typed parameters, and know what comes back, what it costs and when it is refused.
The normalized reads are the part all three vendors share. Everything else a vendor can report is in its adapter’s native catalog: a list of the vendor’s own reads, by name, each with its method, path, parameters and a link to the vendor’s documentation. A native read returns the vendor’s response as it was sent.
Together the two layers cover every read in each vendor’s API reference. The coverage pages account for each endpoint.
| Adapter | Native reads |
|---|---|
| Instantly | 105 |
| Smartlead | 78 |
| HeyReach | 25 |
| In-memory | none |
List the catalog
outbound.native.catalog is data on the adapter. Reading it makes no request, so you can list it with any key, or with none that works.
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
const outbound = createOutbound({ adapter: heyreach() });
// The catalog is data on the adapter. Reading it makes no request.
const { catalog } = outbound.native;
const names = Object.keys(catalog);
console.log(outbound.native.provider, "offers", names.length, "native reads");
console.log(names.slice(0, 6).join(", "), "...");
const read = catalog.campaignsForLead;
console.log(`\n${read.method} ${read.path}`);
console.log(read.description);
console.log(read.docs);
for (const [name, param] of Object.entries(read.params)) {
console.log(
` ${name} (${param.type}, in ${param.in}): ${param.description}`
);
}
heyreach offers 25 native reads
campaignSequenceV2, campaignsForLead, checkApiKey, companiesFromList, conversationsV2, enrichEmailJob ...
POST /campaign/GetCampaignsForLead
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.
https://docs.heyreach.io/hey-reach-api/campaigns/get-campaigns-for-lead
email (string, in body): The lead's email address.
linkedinId (string, in body): The lead's LinkedIn id, `linkedin_id` in lead payloads.
profileUrl (string, in body): The lead's LinkedIn profile URL.
limit (number, in body): How many campaigns to return.
offset (number, in body): How many campaigns to skip.
Each entry is a NativeRead:
| Field | Holds |
|---|---|
description |
What the read answers, in one sentence. |
method |
GET or POST. Some vendors read with POST. |
path |
The vendor’s path, with {name} for each path parameter. |
params |
Each parameter’s type (string, number, boolean, list or json), where it goes (path, query or body), whether it is required, and a description. |
docs |
The vendor’s documentation page for the read. |
lane |
The rate lane it draws on, when the vendor limits it separately. |
baseUrl |
Another host, for a vendor’s sister service. |
The same catalog is what the CLI lists and what the vendor_read tool offers an agent. See the CLI and AI tools.
Run a read
There are two ways to call one:
outbound.native.reads.<name>(params)is typed from the adapter. The name and its parameters autocomplete, and a missing required parameter is a type error.outbound.native.read(name, params)takes a name learned at run time, for a CLI or an agent.
The sample below runs on an injected fetch that answers from the repository’s fixtures/heyreach/ folder. That payload was built from HeyReach’s documented shape with made-up values. It is not a live response.
import { readFileSync } from "node:fs";
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
// Stands in for HeyReach: answers from the documented payloads in the
// repository's fixtures/heyreach/ folder. No request leaves the machine.
const fixture = (name: string): string =>
readFileSync(
new URL(`../../../../fixtures/heyreach/${name}.json`, import.meta.url),
"utf-8"
);
const sent: string[] = [];
const outbound = createOutbound({
adapter: heyreach(),
fetch: async (input, init) => {
const request = new Request(input, init);
const body = await request.text();
sent.push(`${request.method} ${new URL(request.url).pathname} ${body}`);
return new Response(
fixture(
body.includes('"offset":2')
? "getCampaignsForLead.page2"
: "getCampaignsForLead"
),
{ headers: { "content-type": "application/json" } }
);
},
});
// Typed from the adapter: the name and its parameters autocomplete.
const first = await outbound.native.reads.campaignsForLead({
limit: 2,
profileUrl: "https://www.linkedin.com/in/example-person-1",
});
console.log(JSON.stringify(first.data, null, 2));
console.log("requests:", first.meta.requests, "evidence:", first.meta.evidence);
// One call is one request. Page with the vendor's own parameters, here
// HeyReach's offset and limit. `read` takes a name learned at run time.
const second = await outbound.native.read("campaignsForLead", {
limit: 2,
offset: 2,
profileUrl: "https://www.linkedin.com/in/example-person-1",
});
console.log(JSON.stringify(second.data));
console.log(sent.join("\n"));
{
"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"
}
]
}
requests: 1 evidence: exact
{"leadFullName":"Alex Example","totalCount":3,"items":[{"campaignId":274081,"campaignName":"Example campaign three","creationTime":"2024-11-12T14:15:00.000000Z","campaignStatus":"PAUSED","leadStatus":"Excluded"}]}
POST /api/public/campaign/GetCampaignsForLead {"profileUrl":"https://www.linkedin.com/in/example-person-1","limit":2}
POST /api/public/campaign/GetCampaignsForLead {"profileUrl":"https://www.linkedin.com/in/example-person-1","limit":2,"offset":2}
The result is { data, meta }. data is the vendor’s JSON. meta is the same result meta every read carries: here one request, evidence exact.
What a native read is, and is not
- Every entry is a read. The catalog holds nothing that changes vendor data. A vendor read that spends credits is left out and listed as a write on the coverage page.
- One call is one vendor request. There is no
listAll. To page, pass the vendor’s own paging parameters, as the second call above does with HeyReach’soffset. Each vendor pages differently; the parameter descriptions say how. - It goes through the same request engine as a normalized read: it is rate limited, retried, and a failure is an
OutboundErrorwith the key kept out of it. - It is not normalized. Field names, ids, time formats and status values are the vendor’s. There is no
ref,providerorworkspaceIdon what comes back, and code written against one vendor’s native read does not run on another. - It is not redacted. The response can hold names, addresses and message text. Reads that return secrets (API keys, mailbox passwords, proxy credentials) are left out of every catalog.
- Treat the content as untrusted when it reaches a model: it is text a third party wrote. See untrusted content.
When a read is refused
import { createOutbound, OutboundError } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { redact } from "outbound-sdk/redact";
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} - ${error.message}`);
}
};
const outbound = createOutbound({ adapter: heyreach() });
// Each of these is refused before any request.
await attempt("unknown name", () => outbound.native.read("allLeads"));
await attempt("unknown parameter", () =>
outbound.native.read("campaignsForLead", { page: 2 })
);
await attempt("missing parameter", () => outbound.native.read("listById"));
await attempt("wrong type", () =>
outbound.native.read("listById", { listId: "12" })
);
// A native payload is not redacted, so the redact plugin turns native off.
const redacted = createOutbound({ adapter: heyreach(), plugins: [redact()] });
console.log("with redact:", redacted.capabilities.native);
await attempt("with redact", () => redacted.native.reads.checkApiKey());
unknown name: Invalid - heyreach has no native read "allLeads". See outbound.native.catalog
unknown parameter: Invalid - campaignsForLead has no parameter "page". It takes: email, linkedinId, profileUrl, limit, offset
missing parameter: Invalid - listById needs "listId"
wrong type: Invalid - listById: "listId" must be a number
with redact: false
with redact: Unsupported - Reading the vendor's own data is not available: the redact plugin turned it off (native)
| What happened | What you get | What to do |
|---|---|---|
| The name is not in the catalog | Invalid, before any request |
List outbound.native.catalog. |
| A parameter the read does not take, a missing required one, or a wrong type | Invalid, before any request, naming the parameter |
Check the entry’s params. |
| The redact plugin is on | Unsupported: capabilities.native is false |
A native payload cannot be redacted, so native reads and outbound.raw stay off while redact is on. |
| The instance is combined | Unsupported |
outbound.workspace(id).native, when one member carries the label. |
| The adapter has no catalog (in-memory) | Unsupported |
Nothing to call. |
| The vendor refuses (plan, key type, product not enabled) | The vendor’s error as an OutboundError |
See the adapter’s page. |
Branch on outbound.capabilities.native before offering native reads to a caller.
On a combined instance
A native read belongs to one vendor, so outbound.native is not available on a combined instance. Scope first: outbound.workspace(id).native. That works when exactly one member carries the label. A label carried by two members scopes to both, which is still a combined instance. See combine.
For adapter authors: defineNative
An adapter declares its catalog with defineNative, exported from outbound-sdk. It returns the catalog unchanged and keeps each name and parameter as a literal type, which is what types outbound.native.reads. The SDK builds the request from the entry: a path parameter is URL-encoded into the path, a query list becomes a repeated key, a body parameter goes into a JSON body.
import { createOutbound, defineNative } from "outbound-sdk";
import type { Adapter } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
// A catalog for a made-up vendor. `defineNative` keeps each name and each
// parameter as a literal type, so callers get them checked and completed.
const NATIVE = defineNative({
leadNotes: {
description: "Notes written on one lead.",
docs: "https://docs.mytool.example/lead-notes",
method: "GET",
params: {
// A path parameter is always required.
leadId: { description: "The lead.", in: "path", type: "string" },
owners: { description: "Only these owners.", in: "query", type: "list" },
since: {
description: "Only notes from this day on.",
in: "query",
required: true,
type: "string",
},
},
path: "/leads/{leadId}/notes",
},
unreadCount: {
description: "How many replies are unread.",
method: "GET",
path: "/inbox/unread-count",
},
});
// The rest of the adapter is borrowed from the in-memory one to keep this
// short. A real adapter supplies its own reads; see the repository's
// docs/new-adapter.md.
const adapter: Adapter<typeof NATIVE> = {
...memory({ capabilities: { native: true }, name: "mytool" }),
native: NATIVE,
raw: {
authorize: (spec) => ({
...spec,
headers: { ...spec.headers, authorization: "Bearer not-a-real-key" },
}),
baseUrl: "https://api.mytool.example/v1",
isRead: (method) => method === "GET",
},
};
const outbound = createOutbound({
adapter,
// Stands in for the vendor and echoes the URL it was asked for.
fetch: (input) => Promise.resolve(Response.json({ asked: String(input) })),
});
const notes = await outbound.native.reads.leadNotes({
leadId: "lead 42",
owners: ["ana", "ben"],
since: "2026-11-01",
});
console.log(notes.data);
// A read with no required parameter can be called with none.
const unread = await outbound.native.reads.unreadCount();
console.log(unread.data, unread.meta.provider, unread.meta.requests);
{
asked: 'https://api.mytool.example/v1/leads/lead%2042/notes?since=2026-11-01&owners=ana&owners=ben'
}
{ asked: 'https://api.mytool.example/v1/inbox/unread-count' } mytool 1
Rules for a catalog entry, from the adapters in the repository:
- Reads only. A
POSTbelongs in the catalog only when it changes nothing and spends nothing. - Leave out any read that returns secrets, and any parameter that asks for them.
- Declare
native: truein the adapter’s capabilities exactly when the catalog has entries. - Give a read its
lanewhen the vendor limits it separately, and declare the lane in the adapter’s limits.
What the catalog does not name
outbound.raw sends a request to the vendor’s own API by method and path, through the same engine. It is for an endpoint the catalog does not name yet, such as one the vendor added after the inventory was taken. It refuses anything that is not a known read unless the instance was created with allowWrites: true. See raw.
Prefer a catalog read where one exists: its parameters are checked before a request is spent, and it is listed on the coverage page.