HeyReach adapter
Construct the HeyReach adapter, know which key it needs, what it can and cannot read, what its reads cost in requests, the rate limits it keeps to and the assumptions it has not confirmed.
outbound-sdk/heyreach reads HeyReach’s public API. It is the LinkedIn adapter: a sender is a LinkedIn account, and a lead is a LinkedIn profile. It is beta: see what is verified.
Construct it
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
// Reads HEYREACH_API_KEY when `apiKey` is left out. `workspaceId` is your own
// label for the client; it is part of every ref.
const outbound = createOutbound({
adapter: heyreach({ workspaceId: "acme" }),
});
// Nothing below makes a request.
console.log(outbound.provider, outbound.maturity, outbound.workspaceId);
console.log("webhook check:", outbound.capabilities["events.verify"]);
console.log("native reads:", Object.keys(outbound.native.catalog).length);
// The rate budget the SDK keeps to: the declared limit times `share` (0.5).
for (const snapshot of await outbound.limits()) {
for (const window of snapshot.windows) {
console.log(
`${window.lane}: ${window.requests} per ${window.perMs / 1000}s`
);
}
}
// What a read costs in vendor requests, before making it.
for (const action of [
"campaigns.list",
"leads.get",
"leads.lookup",
"senders.list",
]) {
console.log(action, "-", outbound.estimate({ action, records: 250 }).basis);
}
heyreach beta acme
webhook check: secret
native reads: 25
default: 7 per 2s
default: 150 per 60s
organization: 150 per 60s
campaigns.list - 3 page(s) of 100 at 1 request(s) each, plus 0 per record
leads.get - 2 request(s) per page
leads.lookup - 3 page(s) of 100 at 2 request(s) each, plus 0 per record
senders.list - 3 page(s) of 100 at 1 request(s) each, plus 0 per record
| Option | Default | What it is |
|---|---|---|
apiKey |
HEYREACH_API_KEY |
A workspace API key. |
workspaceId |
HEYREACH_WORKSPACE_ID, then default |
Your own label for the client or workspace. It is part of every ref and may not contain a colon. |
baseUrl |
https://api.heyreach.io/api/public |
For a proxy or a test server. |
capabilities |
what the adapter declares | Overrides single capabilities. |
The module also exports envVars (the two variable names), PROVIDER ("heyreach") and NATIVE (the native catalog).
Key and scope
- Create the key in HeyReach under Integrations, HeyReach API. It is scoped to one workspace and does not expire.
- An agency with one workspace per client uses one key per client, each with its own
workspaceIdlabel, behindcombine. - The five organization reads in the native catalog need an organization API key in place of a workspace key and answer
401without it. They were not run with one. - If the account’s plan does not include HeyReach’s campaign API, sequence reads fail with
Unsupportedand a message that namesCAMPAIGN_API_NOT_ENABLED. That is not a missing campaign.
What it can and cannot do
HeyReach is the adapter with the most no in the table:
| Capability | Value | Why |
|---|---|---|
campaigns.settings |
no |
A campaign’s schedule can be written through the API but not read. |
leads.engagement |
no |
No lead shape carries open, click or reply counts. |
leads.list |
no |
There is no list of every lead. Read leads per campaign or per saved list. |
replies.category |
no |
HeyReach’s auto-tags describe the lead, not a message. The newest one is the lead’s interest. |
senders.warmup |
no |
LinkedIn accounts have no warmup. |
sequence.subsequences |
no |
A campaign has one sequence tree and nothing attached to it. |
stats.byStep |
no |
The API has no per-step stats. |
tags |
no |
No endpoint lists the workspace’s tags. A lead’s own tags are read with the lead. |
workspaces |
no |
Only an organization key can list workspaces. |
events.verify |
secret |
HeyReach does not sign webhooks. |
outbound.campaign(id).leads and outbound.list(id).leads work. outbound.leads.list() is refused. leads.lookup works by LinkedIn profile URL and by email.
Rate limits
The adapter declares two windows: 15 requests per 2 seconds and 300 per minute. The 300 is documented. The burst limit is not: it was read from the live API’s 429 response on 2026-11-14. The organization API has a lane of its own at 300 per minute.
At the default share of 0.5 that is 7 requests per 2 seconds, as the output above shows. HeyReach does not document whether the limit is per key, per workspace or per organization, and the workspace’s n8n, Make, Zapier and MCP keys may draw on the same budget. See rate limits.
What reads cost
| Read | Vendor requests |
|---|---|
| One lead | 2: the profile, then its tags |
| Find a person by profile URL | 2 per page: the campaigns they are in, then the profile |
| Find a person by email | about 4, 8 at most: the campaigns, the saved lists the address is in, a name search in one list, then one profile read per match until one carries the address |
| A campaign’s senders | 1 per page, plus 1 once to read the campaign |
| Any other listing or lookup | 1 per page of 100 (a saved list’s leads: up to 1,000 a page) |
An email lookup can come back empty for a person HeyReach knows: no HeyReach read returns a profile for an email address, so an address that is in no saved list is not found.
Vendor behavior that affects your code
- A lead’s id is its LinkedIn profile URL. A person in three campaigns is three records with one ref, told apart by
campaignRef. - Profile URLs match by exact case. The lookup sends the slug in lower case. A profile HeyReach stores with upper-case letters would not be found.
- Which fields a lead has depends on where it was read. A campaign’s leads carry status but no tags and a
nullinterest. A saved list’s leads carry tags andinterestbut no status, campaign or creation time. - A sequence is a tree. Every node that acts on the lead is a step. Condition nodes are not steps: the steps under one carry its outcome as their
branch(accepted,not_accepted,connected,not_connectedand so on). - A thread id is two ids.
message.threadIdis the LinkedIn account id and the conversation id joined by a colon, because HeyReach needs both to read a conversation back. - A message’s campaign is known only when the listing is filtered to one campaign. Otherwise
campaignRefisnull. A page is a page of conversations, so it can hold more messages than thelimityou asked for. - A sender’s
dailyLimitisnull. HeyReach returns several limit pairs and does not say which are daily. They stay inraw. - Lists return a total and no next marker. The end of a list is computed from the offset.
Caveats
outbound.caveats is the adapter’s own list of what it assumes and has not confirmed, and of exactly what the live check covered.
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
const outbound = createOutbound({ adapter: heyreach() });
// What the adapter assumes and has not confirmed. No request is made.
for (const caveat of outbound.caveats) {
console.log(`- ${caveat}\n`);
}
- Checked read-only against one live HeyReach workspace on 2026-11-14: campaigns, sequences, senders, a campaign's senders, sender health, a campaign's leads, lead lists, a list's leads, lead lookup, the blacklist, the webhook list, stats, messages, replies, one conversation by thread id and a lead's timeline all returned valid records. Other workspaces, plans and campaign layouts have not been seen. That workspace had no webhooks, no blacklisted leads and no company lists, so those three shapes come from the documentation alone. Webhook payloads were not checked. The fixtures are built by hand from documented and observed field names, not captured responses.
- The 300 requests per minute limit is documented without a scope (key, workspace or organization) and without rate-limit headers, and the workspace's n8n, Make, Zapier and MCP keys may draw on the same budget.
- Lists return totalCount and no next marker, so the end of a list is computed as offset plus page length reaching totalCount.
- The campaign list's item fields come from a schema that disagrees with the documented GetById example; only id, name, creationTime and status are read. HeyReach campaigns have no tags.
- A campaign's settings cannot be read: the schedule, start and end dates are write-only (UpdateSchedule) and GetById returns none of them. Sending limits belong to the LinkedIn account, not the campaign. The exclusion switches GetById does return stay in raw.
- A campaign's senders are the account list narrowed to the campaign's campaignAccountIds, so an account that was removed from the workspace is left out and a page can come back short or empty.
- A sender's health is its connection state only: connected is true for the status Connected and false for Disconnected, the two values seen; any other status is null. failureReason is the one issue reported. HeyReach caches the status for 10 seconds and gives no check time.
- Campaign statuses are documented by name only. STARTING is mapped to active, SCHEDULED to draft, CANCELED to completed and FAILED to unknown.
- A sender's status is derived from two undefined booleans: authIsValid false is disconnected, otherwise isActive true is active and false is paused.
- A sender's dailyLimit is null: accountLimits holds several limit pairs and the docs do not say which are daily or what Limit and Max mean. They stay in raw.
- A lead's id is its LinkedIn profile URL, the only key GetLead accepts and the only one present in every lead shape. The docs do not say the URL has one canonical form.
- A lead is looked up by profile URL with two requests (GetCampaignsForLead and GetLead) and returned once per campaign it is in. A lead's id is its profile URL in every campaign, so a person's records share one ref and differ by campaignRef. The timeline is the lead's whole conversation history, the same on each of its records.
- A looked-up lead's status follows leadStatus and never says replied, because GetCampaignsForLead reports no message status; Excluded and PendingOrExcludedToBeCalculated are unknown. createdAt is null (the creationTime beside a campaign is the campaign's), tags are empty and interest is null. A lookup cannot be combined with a saved list or a date range.
- HeyReach matches a profile URL whatever its scheme, www prefix, trailing slash or query, but by exact case: seen live, a slug sent in upper case is not found. The lookup sends the slug lowercased, so a profile HeyReach stores with upper-case letters would not be found. A slug with non-ASCII letters is sent percent-encoded; how HeyReach stores one is not known.
- No HeyReach read returns a profile for an email address. An email lookup asks for the campaigns (which names the person), then one saved list the address is in, searches that list for the name (5 rows at most) and reads each match's profile until one carries the address: four requests in the usual case. An address that is in no saved list, or whose profile is not among those rows, gives an empty page although HeyReach knows it. The record's email is the address asked for, which the profile may hold as its enriched or custom address.
- Lookup was checked against one live lead in one campaign. A person in several campaigns (and so a second page of campaigns) and a known person in no campaign were not seen; an empty campaign answer and a 404 from GetCampaignsForLead are both read as no campaigns.
- HeyReach has no list of every lead. Leads are read per campaign or per saved list: outbound.campaign(id).leads and outbound.list(id).leads work, and outbound.leads.list() is refused. Walking every list would repeat a lead that sits in several lists, drop its campaign and status, and miss leads whose list was deleted, so it is not offered as a complete list.
- A saved list's leads carry tags, custom fields and the auto-tag but no status, campaign or creation time, although the list can be filtered by when a lead was added. A company list's rows are companies, not leads, and are not mapped: read them with outbound.native.reads.companiesFromList.
- A lead's interest is the name of its newest auto-tag (Interested, Not interested and Generic were seen live; the API reference does not list the names). It is present on a list's leads and absent from a campaign's leads and from lead lookup, where it is null.
- A lead's tags come from the list row, or from a second request (POST /lead/GetTags) on lead lookup. A campaign's leads carry no tags, so theirs are empty.
- No lead shape carries open, click or reply counts, so engagement is always null. lastActionTime, leadConnectionStatus and leadMessageStatus stay in raw.
- A campaign's lead list was seen live to include finished leads, although the documentation describes it as pending leads only.
- A lead's status is replied when leadMessageStatus is MessageReply, otherwise it follows leadCampaignStatus with Pending as active. Failed has no equivalent and is unknown.
- A lead's email falls back from emailAddress to enrichedEmailAddress to customEmailAddress. The docs do not say which of the three is the newest.
- A sequence is a node tree and every node that acts on the lead is a step. Steps are numbered node first, then its conditional branch, then its unconditional branch, so an index is stable until the tree is edited. CHECK_ nodes are conditions, not steps: the steps under one share its parent and carry its outcome as their branch, so a campaign that opens with CHECK_IS_CONNECTION has two steps with no parent.
- Branch names are ours for the documented conditions: accepted and not_accepted after CONNECTION_REQUEST, connected and not_connected after CHECK_IS_CONNECTION, open_profile and not_open_profile after CHECK_IS_OPEN_PROFILE, email_found and email_not_found after FIND_EMAIL. Two conditions in a row are joined with a plus sign. Any other node with steps on its conditional branch uses HeyReach's own words, conditional and unconditional. Only the CONNECTION_REQUEST and CHECK_IS_CONNECTION layouts were seen live.
- The step after a MESSAGE or INMAIL has no branch: a reply ends the sequence, so every later step already means the lead has not replied. A step's delayDays includes the wait of any CHECK_ node between it and its parent.
- Sequence variants come from payload.messages (or messageVersions), which the docs describe as A/B variants for MESSAGE nodes only; INMAIL and LIKE_POST payloads are not documented.
- Stats: replied is totalMessageReplies plus totalInmailReplies. contacted is uniqueLeadsContacted and positive is autoTaggedInterested; the live API returns both but the API reference lists neither, so they are null when absent. totalMessageStarted and totalInmailStarted are not defined in the docs and are not read.
- Daily stats are keyed by UTC midnight; the docs do not say which timezone buckets a day or whether endDate is inclusive. Stats need both from and to, or neither.
- Messages and replies are read from conversations. Checked against one live workspace on 2026-11-14: sender is ME or CORRESPONDENT, and each of 257 conversations carried its whole thread (longest read: 40 messages). Whether a longer thread is cut short is not known.
- Conversations are listed with GetConversationsV3, which pages by cursor and bounds a conversation by the time of its newest message. A since filter is sent as that lower bound and the older messages inside each conversation are then dropped, so a page can hold fewer messages than its conversations do. An until filter cannot be sent, because a conversation that continued past it still holds earlier messages; it is applied after fetching. A page marker saved from before the move to V3 (a number) is not valid and starts from the first page.
- A message's id is built from the conversation, the time, the side and the text, so an edited message gets a new id. The live API now returns a messageId the API reference does not list; it stays in raw so that existing ids do not change.
- A message's threadId is the LinkedIn account id and the conversation id joined by a colon, because one conversation can only be read back with both. A thread read returns the whole conversation in one page and does not know its campaign, so campaignRef is null.
- A message's campaign is known only when the listing is filtered to one campaign; otherwise campaignRef is null. A page is a page of conversations, so it can hold more messages than the limit asked for.
- Replies carry no category: HeyReach's auto-tag is made from a lead's first campaign reply but is stored on the lead, with no link to a message. It is read as the lead's interest and counted in stats as positive.
- The blacklist is two lists read one after the other, leads then companies. An entry's id is prefixed lead- or company- because the two are numbered separately. A blacklisted lead's value is its profile URL, else its email, else its name (kind other); a company's is its name, else its LinkedIn page. matchingStatus and resolutionStage stay in raw.
- A webhook subscription lists one event type. Filtering webhooks by campaign is done after fetching and keeps webhooks that cover every campaign. Custom headers are never requested, because they can hold a secret; a request for one webhook by id (native webhookById, or raw) is always sent with includeCustomHeaders=false. HeyReach gives a webhook no creation time.
- Native reads return HeyReach's own payloads. Checked read-only against the same live workspace on 2026-11-14: checkApiKey, listById, campaignsForLead, myNetworkForSender, isConnection, campaignSequenceV2, statsByCampaign, proxyCountries, conversationsV2, voices, staticClips, elevenLabsConnection, elevenLabsVoices and voiceUsage answered 200; listsForLead, staticClip, senderVoice, webhookById and enrichEmailJob answered a documented 404 because the workspace had nothing to return; companiesFromList answered 400 for a lead list, the workspace having no company list. The five organization reads answer 401 with a workspace key and were not seen with an organization key.
- Three documented reads are not offered: the workspace API keys and a LinkedIn account's proxy (both return secrets, and raw refuses them), and a voice message's audio file, which is not JSON and would not survive the request engine, which reads every body as text. A voice message's transcript and duration are in its conversation.
- The workspace's tags cannot be listed: the API creates tags and reads one lead's tags, nothing more. A tag has a name and a colour and no id.
- Only the voice_note_received webhook payload is documented. Other events are assumed to share its skeleton, and the lowercase event_type names are inferred from two examples.
- Webhooks are not signed; correlation_id is the only identifier and is assumed to be stable across redelivery.
Webhooks
Verification mode: secret. HeyReach does not sign payloads, so put a secret in the webhook URL or in a custom header named x-webhook-secret and pass the same value as secret when parsing. HeyReach documents one payload only (voice_note_received); the other events are assumed to share its shape. See webhook verification.
Coverage
HeyReach coverage lists all 106 endpoints of the public API reference: 17 behind normalized reads, 25 in the native catalog, 3 excluded and 61 writes. The research note the adapter was written from is in the repository at docs/research/heyreach.md.