Systems Lab

Agent skill

heyreach-api

Operate HeyReach (LinkedIn outreach) end-to-end via API and MCP.

activeNeeds a keyInstructions only5,022 words

Filed under Outbound email.

From OneGTM/gtm-skills · 9 skill entries · 0 · pushed 2026-09-30

What it does when it runs

Operate HeyReach (LinkedIn outreach) end-to-end via API and MCP. Master reference for every operation, exact variable syntax (single-brace; always use custom {FNAME}/{CNAME}), the sequence node tree, connection/message/InMail payloads + fallbacks, schedule/sender/tag/webhook shapes, status-gated edits (Update Sequence/Settings/Schedule/Accounts, now also via MCP), the required-variable preflight guard, the 100-lead cap, delete being UI-only, and workarounds (pause-to-edit, resume-to-restart, note: adding leads does NOT wake a paused campaign). Use whenever creating, editing/modifying, deleting, loading leads into, launching, or analyzing HeyReach campaigns. Pairs with `instantly-copywriter` for copy standards.

Automated analysis of the skill and the 1 file bundled beside it. A skill’s own description is written to be selected by an agent, so it describes the job and not the dependencies.

Keys and connectors you must supply
  • HEYREACH_API_KEY
Hosts it reaches
  • api.heyreach.io
  • www.heyreach.io
  • www.linkedin.com
Tool permissions it declares
No allowed-tools in the frontmatter. It only issues instructions, so there is nothing to bound.
Actions present in the files
None. Instructions only.

Ask about heyreach-api

Opens your assistant with this page's verified links already in the prompt.

Is this safe to install?ClaudeChatGPT
Adapt it to my stackClaudeChatGPT
What else do I need for it to workClaudeChatGPT
Rather ask a human? Talk to Cheetah
git clone --depth 1 --filter=blob:none --sparse https://github.com/OneGTM/gtm-skills.git /tmp/gtm-skills
git -C /tmp/gtm-skills sparse-checkout set "skills/heyreach-api"
mkdir -p ~/.claude/skills/heyreach-api
cp -R "/tmp/gtm-skills/skills/heyreach-api/." ~/.claude/skills/heyreach-api/

Picked up without a restart. A project skill of the same name is shadowed by your personal one. For one repository only, swap ~/.claude/skills for .claude/skills. Claude Code docs ↗

Or take the whole library

This repo ships a .claude-plugin manifest, so Claude Code can install all 9 skills at once. Plugin skills are invoked as /<plugin>:<skill>, so they never collide with your own.

/plugin marketplace add OneGTM/gtm-skills
/plugin

The folder is the same in every client that implements the format — 46 of them — so if yours is not above, only the destination changes.

Before you install: this skill will not complete its job on a bare agent. It needs HEYREACH_API_KEY, which you have to obtain separately.

Reproduced in full from OneGTM/gtm-skills/blob/36da828338f8062c90f6a4a8ea8b918e4160f1e5/skills/heyreach-api/SKILL.md, which is licensed MIT (repository). 5,022 words, 27 headings.

HeyReach API + MCP (Shared)

Everything you can and cannot do with HeyReach, the optimal way to do each thing, and the workarounds. This skill owns mechanics. Copy standards (no em dashes, no comp numbers, humanization) are shared with instantly-copywriter.

Sibling skill: instantly-api (email). Keep them separate, HeyReach uses X-API-KEY (not Bearer), single curly braces, and a sequence tree (not a flat step list).

Live-validated 2026-06-10 against a live production workspace: X-API-KEY auth, GET /auth/CheckApiKey (200), POST /campaign/GetAll (status enum DRAFT/PAUSED/IN_PROGRESS), POST /list/GetAll (USER_LIST), GET /campaign/GetCampaignSequence (full node tree, messages:[] blank connection, fallbackMessage = text-minus-variable, single-brace custom {FNAME}/{PASTCO}), POST /li_account/GetAll (correct path; /linkedinaccount/... → 404), and the gating workaround: POST /campaign/UpdateSchedule on an IN_PROGRESS campaign returns HTTP 400 {"errorMessage":"Invalid campaign status."} (so Pause first). Edge cases proven: 100-lead cap is schema-enforced (101→400, empty→400); bad listId is a silent 200 no-op (zero counts, no error); no list-delete endpoint exists (all 404); campaign/Create validates schema→list-existence→sequence; name>50→400. Captured GetById/lead-object/GetConversationsV2/GetOverallStats shapes (§10). Path corrections verified: CreateCampaignFromTemplate (not CreateFromTemplate), li_account/GetById, lead/GetTags|AddTags|ReplaceTags, inbox/SendMessage; CreateEmptyList field is name not listName. Reactivation claim disproven: adding leads to a manually PAUSED campaign does NOT resume it (stays PAUSED), use Resume. Delay rule: actionDelay ∈ 0-100 + unit HOUR/DAY only (MINUTE→500; >100→400; max reach 100 DAY); min 3 HOURS on every node incl END, sub-3h HOUR values (incl 0) → 400, so author ≥3 HOUR or ≥1 DAY (re-verified 2026-06-12). Action nodes mapped (VIEW_PROFILE/FOLLOW no payload; INMAIL empty→500; LIKE_POST optional; SEND_LEAD_* validate resource id; FOLLOW can't follow CONNECTION_REQUEST; MESSAGE auto-gets a stop-if-replied END). GetById 404s on a just-created campaign (use GetAll). Delete-leads-from-list is DELETE + body field ProfileUrls (POST→405 silent no-op; leadProfileUrls→400), verified. MESSAGE nodes may carry a conditionalNode (not unconditional-only). UpdateSequence success = empty 200 body. AddLeadsToListV2 silently drops leads missing firstName/profileUrl. NB: HeyReach has no Cloudflare UA ban (default Python urllib/requests works, unlike Instantly). Untested-but-documented (avoids real outreach): StartCampaign send behavior, FINISHED-campaign reactivation, webhook writes (REST path also unconfirmed).


Silent failure modes (check these FIRST when something "worked" but didn't)

These all return success / no error yet do the wrong thing, the expensive ones:

SymptomCauseFix
Lead-delete "succeeded" but list didn't shrinkPOST to delete-leads → 405 no-opuse DELETE + body field ProfileUrls (not leadProfileUrls)
Load returns 200 but 0 addedwrong listId → silent 200, zero countsassert added+updated>0 (the helper raises)
Fewer leads loaded than sentleads missing firstName/profileUrl silently droppedpreflight (§3.1) before load
Messages send generic, not personalizedempty custom var → silent fallbackMessagepreflight requires every {FNAME}/{CNAME}/custom token
Outreach goes out at wrong hoursomitted timeZoneId → defaults to Etc/GMT (London)ALWAYS set America/New_York (§6)
Paused campaign won't restart after adding leadsadding leads does NOT resume ituse Resume (§10)
create_campaign 400 / 500bad node delay (0/>100/MINUTE), INMAIL empty payload, FOLLOW after CONNECTION_REQUESTrun validate_campaign() (§5)
Can't read a campaign's timezone to verifyschedule isn't API-readable (GetById omits it)check in UI, or blind-set via UpdateSchedule
GetById 404 on a campaign you just madepropagation lagread status via GetAll

Quickest safety net: put every fix above into one small heyreach.py client and import it everywhere.

0. Golden rules

  1. Prefer the raw API over MCP. The MCP is noticeably slow on bulk work; the API is cleaner and cheaper. Drop to MCP only for quick reads. (See §9.)
  2. Always personalize with custom {FNAME} and {CNAME}, NOT the built-in {FIRST_NAME}/{COMPANY_NAME}. Built-ins pull straight from the lead's LinkedIn profile, often bad capitalization/spelling ("jane", "ACME LLC"). We control FNAME/CNAME ourselves via customUserFields, so they're clean. This is the standard. (§3)
  3. Always supply a fallbackMessage on every message that uses a variable, it's sent when the lead lacks the variable, so you never deliver a broken {TOKEN} literal.
  4. Multiple HeyReach MCP servers are usually connected, one per client workspace (often named <Client>_HeyReach_MCP), plus others. Always select the server bound to the workspace you're operating in; the rest point at other portals (wrong one = wrong account).
  5. 100 leads per add call. Chunk every import in batches of 100.
  6. Run the required-variable preflight guard before every load (§3.1). Never pipe in a lead missing profileUrl/firstName or any custom token ({FNAME}/{CNAME}/…) the copy uses, HeyReach drops or falls-back silently. Upload only leads that pass; surface the rest.
  7. Two non-negotiable campaign defaults (set on every create unless told otherwise):
    • excludeContactedFromSenderInOtherCampaign: true, don't let a sender re-touch a lead it already contacted elsewhere (§5.1).
    • timeZoneId: "America/New_York" (US Eastern), never omit the timezone; omitting → Etc/GMT/London (the wrong-hours bug). (§6)

1. Auth & base URL

  • Base: https://api.heyreach.io/api/public, every path is under it.
  • Auth: header X-API-KEY: <key> (NOT Authorization: Bearer). Content-Type: application/json on POSTs.
  • Validate key: GET /auth/CheckApiKey → 200 if valid.
  • Key: HeyReach app → Settings → API. Client key in .env as HEYREACH_API_KEY.
  • Rate limit: 300 requests/minute.
  • No reachable OpenAPI/Swagger; canonical docs are the Postman collection. The live MCP tool schemas are the highest-fidelity source for exact params/enums.

2. Operation map (what's possible)

AreaOperationMethod + pathNotes
All paths below marked ✅ are verified live (real call returned 200, or 400/404-with-error proving the route exists). Paths marked ❓ are MCP-confirmed operations whose literal REST path I could not confirm by probing (use the MCP tool, or open the Postman collection in-browser for the exact string).
AreaOperationMethod + pathNotes
CampaignCreate✅ POST /campaign/CreateLands in DRAFT. Returns {campaignId}. §5
Create from template✅ POST /campaign/CreateCampaignFromTemplateNOT /CreateFromTemplate (404). Body needs LinkedInAcccountIdsForCampaign (triple-c typo). Clones into DRAFT
Get / Get all✅ GET /campaign/GetById?campaignId= · ✅ POST /campaign/GetAllGetById fields in §10
Start (go live)✅ POST /campaign/StartCampaign?campaignId=DRAFT only. The real activation call
Pause / Resume✅ POST /campaign/Pause?campaignId= · ✅ POST /campaign/Resume?campaignId=Resume rejects DRAFT, use Start for a DRAFT
Add leads V2✅ POST /campaign/AddLeadsToCampaignV2accountLeadPairs[]. ≤100. Does NOT reactivate a PAUSED campaign (verified). §4, §10
Stop one lead✅ POST /campaign/StopLeadInCampaignTakes lead id or profileUrl
Get leads from campaign✅ POST /campaign/GetLeadsFromCampaign{campaignId,...}
Get campaigns for lead✅ POST /campaign/GetCampaignsForLeadneeds email / linkedInId / profileUrl
Get sequence✅ GET /campaign/GetCampaignSequence?campaignId=Returns the node tree, reuse it directly
Update sequence✅ POST /campaign/UpdateSequenceDRAFT/SCHEDULED/PAUSED only
Update schedule✅ POST /campaign/UpdateScheduleDRAFT/SCHEDULED/PAUSED only (else 400 "Invalid campaign status")
Update accounts✅ POST /campaign/UpdateAccountsneeds LinkedInAccountIds; full replace of senders
Update settings✅ POST /campaign/UpdateSettingsneeds Name; not on IN_PROGRESS/FINISHED
Delete,❌ NO API delete (404). UI only.
ListsCreate empty✅ POST /list/CreateEmptyList{name, listType}, field is name not listName (verified; listName → 400)
Get all / by id✅ POST /list/GetAll · ✅ GET /list/GetById?listId=
Add leads V2✅ POST /list/AddLeadsToListV2{listId, leads[]}. ≤100. §4
Get leads✅ POST /list/GetLeadsFromListlimit ≤1000. Lead-object fields in §10
Get companies✅ POST /list/GetCompaniesFromListCOMPANY_LIST
Delete leads by URL✅ DELETE /list/DeleteLeadsFromListByProfileUrl⚠ HTTP DELETE (POST → 405) and body field is ProfileUrls (leadProfileUrls → 400). {listId, ProfileUrls:[...]} → 200 {notFoundInList:[...]}. §4
Delete list,❌ NO API delete (404). UI only.
SendersGet all / by id✅ POST /li_account/GetAll · ✅ GET /li_account/GetById?accountId=NOT /linkedinaccount/... (404). limit ≤100
My network for sender✅ POST /MyNetwork/GetMyNetworkForSender{senderId, pageNumber, pageSize}, NOT offset/limit: those are accepted and ignored, every call returns the same first 20 rows (measured 2026-09-29, OneGTM: 13,120 rows pulled, 20 unique). pageNumber is 0-based; pageSize 100 works. Dedupe by URL and refuse a page of repeats
Lead / TagsGet lead✅ POST /lead/GetLeadby profileUrl / linkedInId
Get / Add / Replace tags✅ POST /lead/GetTags · ✅ POST /lead/AddTags · ✅ POST /lead/ReplaceTagsTags are strings, not IDs. profileUrl XOR leadLinkedInId. §7
InboxConversations✅ POST /inbox/GetConversationsV2shape in §10
Send message✅ POST /inbox/SendMessage{conversationId, linkedInAccountId, message}
Get chatroom❓ (MCP get_chatroom)REST path unconfirmed; use GetConversationsV2
StatsOverall✅ POST /stats/GetOverallStats{accountIds:[], campaignIds:[]}; shape in §10
WebhooksCreate✅ POST /webhooks/CreateWebhook{webhookName, webhookUrl, eventType, campaignIds:[]} (empty = all campaigns) → 200 empty body. /webhook/CreateWebhook and /webhooks/create 404. No custom headers/secret, the receiver can't verify it, so filter by campaign server-side. Verified 2026-09-18. Event enum §8
List / update / delete❓ (MCP *_webhook tools)REST paths unconfirmed. Use MCP

3. Variables & personalization: EXACT syntax

HeyReach uses SINGLE curly braces. {FIRST_NAME}, not {{...}} (double braces are third-party tooling like Clay, not native HeyReach).

Built-in tokens (pull from the lead's LinkedIn profile)

{FIRST_NAME}  {LAST_NAME}  {COMPANY_NAME}  {POSITION}  {LOCATION}  {SUMMARY}  {ABOUT}

⚠ Don't use these for name/company in real copy. They render whatever LinkedIn has, frequently lowercase or messy ("jane", "ACME, LLC."). Confirm exact casing in-app if you ever need one.

The standard: custom {FNAME} and {CNAME} (USE THESE)

Populate customUserFields on every lead with clean, properly-cased values, and write copy against them:

"customUserFields": [
  { "name": "FNAME", "value": "Jane" },
  { "name": "CNAME", "value": "Acme" }
]
  • Copy uses {FNAME}, {CNAME} (and any other custom field, e.g. {PASTCO}).
  • Naming rule: keep customUserFields[].name to letters, digits, and underscores (e.g. FNAME, AI_Icebreaker_1). HeyReach/Clay docs state this as a constraint; it is not rejected by the add-leads request schema (a bad name doesn't 400), so enforcement is at processing/UI time, follow the rule anyway to be safe.
  • Custom fields are matched by exact name; tokens are case-sensitive to the field name you set.

Fallback messages (required when you use a variable)

Every message-bearing payload carries a fallbackMessage that mirrors the text with the variable removed, used when the lead lacks that field:

{ "messages": ["Hey {FNAME}, saw you lead the team at {CNAME}..."],
  "fallbackMessage": "Hey, saw your work in the space..." }
  • CONNECTION_REQUEST / MESSAGE → fallbackMessage is a string.
  • INMAIL → fallbackMessage is an object {subject, message}.
  • Convention in our scripts: pass fallbackMessage = primary text minus the variable; if a step has no variable, fallback can equal the primary text.

Spintax / variations

HeyReach personalization is variable + fallback based; it does not use Instantly's {{RANDOM|...}} spintax. To vary copy, provide multiple entries in the messages array of a payload (A/B note/message variants) rather than inline spintax.

3.1 Required-variable preflight guard (run before EVERY load: mandatory)

Never push a lead that is missing a variable the copy uses. HeyReach won't error, it silently drops leads with no firstName/profileUrl (§4) and silently falls back to fallbackMessage whenever a custom token like {FNAME}/{CNAME}/{PASTCO} is empty, so a half-populated load looks fine but sends generic, un-personalized messages. The guard is the only thing standing between you and that.

What to require:

  1. Always: profileUrl (non-empty, normalized) and firstName, else the lead is silently dropped at load.
  2. Every custom token used anywhere in the sequence ({FNAME}, {CNAME}, and any others) must have a matching non-empty entry in that lead's customUserFields. Extract the tokens from your sequence copy and require all of them.
  3. Because we standardize on {FNAME}/{CNAME} (never the messy built-ins, §3), those two are effectively always required.

How: split leads into ok / missing, upload only ok, and surface missing (count + a sample + which field each lacks) for the operator to fix, don't silently proceed. See the preflight() recipe in references/recipes.md. This applies whether you load via API or MCP, neither validates copy variables for you.


4. Leads: loading (the 100-cap workhorse)

Two destinations:

Add to a LIST, flat array:

POST /list/AddLeadsToListV2
{ "listId": 67890, "leads": [ {LEAD}, ... ] }     // 1-100 per call

Add to a CAMPAIGN, account/lead pairs (pin a lead to a specific sender):

POST /campaign/AddLeadsToCampaignV2
{ "campaignId": 12345,
  "accountLeadPairs": [ { "lead": {LEAD}, "linkedInAccountId": 12345 } ] }  // accountId optional

Lead object:

{ "firstName": "Jane", "lastName": "Doe",
  "profileUrl": "https://www.linkedin.com/in/jane-doe",   // the identifier
  "companyName": "Acme", "position": "VP Sales",
  "location": null, "summary": null, "emailAddress": null,
  "customUserFields": [ {"name":"FNAME","value":"Jane"}, {"name":"CNAME","value":"Acme"} ] }
  • Cap: 100 leads/call for both V2 adds, enforced at the schema level: 101 → HTTP 400 "The field Leads must be a string or array type with a maximum length of '100'" (campaign side: AccountLeadPairs same cap). Empty leads:[] → 400 (min length 1). Always chunk.
  • Response: {addedLeadsCount, updatedLeadsCount, failedLeadsCount}, log all three.
  • ⚠ Silent no-op on a bad listId: adding leads to a non-existent list returns HTTP 200 with all-zero counts (added=0, updated=0, failed=0), NOT an error. Always assert addedLeadsCount + updatedLeadsCount > 0, or you'll think a load succeeded when it loaded nothing. (Verified live.)
  • Silently drops leads missing/empty firstName OR profileUrl, they don't error, they just don't load (e.g. 615 of 617 added, the 2 short ones vanish). The shortfall only shows as addedLeadsCount < len(leads), so reconcile counts. Normalize profileUrl to https://www.linkedin.com/in/... before sending; bad/mismatched URLs get silently dropped too.
  • For very large one-time loads, the HeyReach UI CSV drag-drop imports everything in one action (no 100-cap), faster than ~13 API calls.
  • GetLeadsFromList: limit ≤1000 (default 100), offset zero-based.
  • Remove from a list: ⚠ DELETE /list/DeleteLeadsFromListByProfileUrl with body {listId, ProfileUrls:[...]} → 200 {notFoundInList:[...]} (lists the URLs that didn't match). Verified gotchas: the POST form returns 405 with an empty body (so if status==200 silently no-ops and the list never trims, a costly trap); the field is ProfileUrls, not leadProfileUrls (which 400s). Normalize each profileUrl to the stored format (https://www.linkedin.com/in/...) first, or it won't match and lands in notFoundInList.

5. Campaigns & the sequence tree

5.1 Create

POST /campaign/Create
{ "name": "My Campaign",                   // 1-50 chars
  "linkedInUserListId": 67891,            // must be a USER_LIST
  "linkedInAccountIds": [12345],           // sender account ids (from li_account/GetAll), 1-100
  "excludeContactedFromSenderInOtherCampaign": true,   // ⭐ ALWAYS true (default), see below
  "excludeContactedFromOtherCampaigns": false,
  "excludeHasOtherAccConversations": false,
  "excludeListId": null,
  "schedule": { CampaignScheduleApiDto },  // §6, ALWAYS set timeZoneId (omitting → GMT/London)
  "sequence": { NODE_TREE } }              // §5.2 (optional)
→ { "campaignId": 12345 }

⭐ Default excludeContactedFromSenderInOtherCampaign: true on every create. This stops a sender from re-touching a lead that same account already contacted in another campaign, prevents duplicate/awkward outreach from one LinkedIn account. Set it on by default; only turn off if explicitly told to. (The other two excludes, excludeContactedFromOtherCampaigns = any account, and excludeHasOtherAccConversations = leads already in a conversation with any of your accounts, are optional; enable when the use case calls for it.) Create-from-template (verified path POST /campaign/CreateCampaignFromTemplate) clones an existing campaign's sequence+schedule: {campaignIdForTemplate, name, linkedInUserListId, linkedInAcccountIdsForCampaign} (note the API's triple-c typo in the account-ids field, verified live).

5.2 Sequence = a tree of nodes

Each node:

{ "nodeType": "CONNECTION_REQUEST",
  "actionDelay": 3, "actionDelayUnit": "HOUR",   // unit: HOUR | DAY
  "payload": { ... },                            // type-specific, §5.3
  "unconditionalNode": { ...next... },           // fall-through / next step
  "conditionalNode": { ...if-met... } }          // branching nodes only

Delay semantics (verified live): actionDelay+actionDelayUnit is the wait BEFORE this node fires, measured from the previous node (or, on a branch, from the trigger, e.g. connection accepted). So the connection request itself waits before it's sent; each follow-up node's actionDelay is the gap after the prior step. ⚠ This is the opposite of Instantly, where delay is the gap after a step before the next one.

Hard delay rule (verified): actionDelay is an integer 0-100 (>100 → 400 "must be between 0 and 100"), and actionDelayUnit ∈ HOUR | DAY only (MINUTE/other → 500 server error). So the maximum reach of any single step is 100 DAY (~3.3 months) or 100 HOUR, NOT "500 days" (the "500 days" wording in some error text is misleading; the enforced cap is the 0-100 number).

  • Minimum delay is 3 HOURS on EVERY node, END included (re-verified 2026-06-12). A node with actionDelay: 1, "HOUR" is rejected: 400 "Node at: …/UNCND-END has invalid delay: 01:00:00, delay must be at least 3 hours and not more that 500 days". This floor applies to terminal/branch END nodes too, not just action nodes (an earlier note here claimed 1 HOUR/2 HOUR were accepted, that's wrong on the live UpdateSequence path). Safe practice: author every node, END included, with actionDelay ≥ 3 when unit is HOUR, or ≥ 1 DAY. (GetCampaignSequence still reads back server-added END nodes as 0 HOUR, but you cannot POST 0 or any sub-3-hour HOUR value.) The "not more that 500 days" wording is still misleading, the real numeric cap is the 0-100 integer (§5.2 above). Node types: CONNECTION_REQUEST, MESSAGE, INMAIL, VIEW_PROFILE, FOLLOW, LIKE_POST, END, CHECK_IS_CONNECTION, CHECK_IS_OPEN_PROFILE, FIND_EMAIL, SEND_LEAD_TO_INSTANTLY, SEND_LEAD_TO_SMARTLEAD, SEND_LEAD_TO_BISON.
  • Branching (need BOTH conditionalNode + unconditionalNode): CONNECTION_REQUEST, CHECK_IS_CONNECTION, CHECK_IS_OPEN_PROFILE, FIND_EMAIL.
  • Action: MESSAGE, INMAIL, VIEW_PROFILE, FOLLOW, LIKE_POST, SEND_LEAD_TO_*, chain the next step via unconditionalNode. MESSAGE (and INMAIL) may ALSO carry a conditionalNode (the "stop-if-replied" branch, typically pointing at END), verified accepted on Create and present in live trees. So it's not strictly unconditionalNode-only; the conditional branch is optional but allowed.
  • Terminal: END (no children). Every path must end in an END node or validation fails.
  • Per-node actionDelay = integer 0-100, unit HOUR/DAY (see semantics above; author with ≥1). Build the tree by linking nodes tail-first (see recipe).
  • Easiest way to get a valid tree: call GetCampaignSequence on a known-good campaign and reuse the JSON.

5.3 Payloads by node type

// CONNECTION_REQUEST  (note ≤300 chars; empty array = blank/no-note request)
{ "messages": ["Hi {FNAME}, quick note about {CNAME}..."], "fallbackMessage": "Hi, quick note...",
  "toBeWithdrawnAfterDays": 14 }            // ≥14
// blank connection request:
{ "messages": [] }

// MESSAGE  (≤8000 chars)
{ "messages": ["Thanks for connecting, {FNAME}..."], "fallbackMessage": "Thanks for connecting..." }

// INMAIL  (subject ≤200, message ≤1900; fallback is an OBJECT)
{ "messages": [ {"subject": "Quick idea, {FNAME}", "message": "..."} ],
  "fallbackMessage": {"subject": "Quick idea", "message": "..."} }

// LIKE_POST, payload OPTIONAL (empty {} accepted; these are the defaults)
{ "reactionType": "LIKE", "randomReaction": false, "reactBefore": "WEEK1", "skipDelayIfCannotLike": false }
// reactionType ∈ LIKE|CELEBRATE|SUPPORT|FUNNY|LOVE|INSIGHTFUL|CURIOUS ; reactBefore ∈ DAY1|DAY3|WEEK1|WEEK2|MONTH1|MONTH3

// VIEW_PROFILE, NO payload required (empty node accepted)
// FOLLOW, NO payload required

// SEND_LEAD_TO_INSTANTLY, instantlyResourceId is VALIDATED (bad id → 400 "provided list id is invalid")
{ "instantlyResourceId": "UUID", "resourceType": "LIST" }   // LIST | CAMPAIGN
// SEND_LEAD_TO_SMARTLEAD, { "smartLeadCampaignId": 12345 }  (validated → 400 if bad)
// SEND_LEAD_TO_BISON, empty payload accepted (not validated at create)

Per-node-type behavior (verified live):

NodePayloadNotes
CONNECTION_REQUEST{messages[≤300], fallbackMessage, toBeWithdrawnAfterDays≥14}; messages:[] = blank requestbranching (conditional=after-accept, unconditional=never-accepts)
MESSAGE{messages[≤8000], fallbackMessage}server auto-adds a conditionalNode: END (stop-if-replied), you don't need to send it
INMAILREQUIRED {messages:[{subject≤200, message≤1900}], fallbackMessage:{subject,message}}⚠ empty payload → 500 server error (always send a real payload)
LIKE_POSToptional (defaults above)
VIEW_PROFILE, FOLLOWnone
SEND_LEAD_TO_INSTANTLY/SMARTLEADresource id, validated at create
SEND_LEAD_TO_BISONnot validated at create
CHECK_IS_CONNECTION (internal IS_CONNECTION), CHECK_IS_OPEN_PROFILE, FIND_EMAILnonebranching (need both child branches)

Sequencing rules (verified): FOLLOW cannot directly follow a CONNECTION_REQUEST (→ 400 "CONNECTION_REQUEST already follows the lead"), put FOLLOW on a non-connection branch (e.g. after VIEW_PROFILE). CHECK_IS_CONNECTION also has placement constraints (400 "Cannot have a MESSAGE before IS_CONNECTION"). Verified-good chain: VIEW_PROFILE → FOLLOW → LIKE_POST → MESSAGE → END (200). When unsure, clone a real tree via GetCampaignSequence.

5.4 Statuses & which edits each allows

Statuses: DRAFT, SCHEDULED, STARTING, IN_PROGRESS (= active/running), PAUSED, FINISHED (= completed), CANCELED, FAILED.

EditAllowed whenSide effect
UpdateSequenceDRAFT, SCHEDULED, PAUSEDSCHEDULED → reverts to DRAFT. Success = HTTP 200 with empty body (don't json.parse it); invalid sequence → 400 {errors}. Edits a DRAFT in place.
UpdateScheduleDRAFT, SCHEDULED, PAUSEDSCHEDULED → reverts to DRAFT
UpdateAccountsDRAFT, SCHEDULED, PAUSEDOn PAUSED, removing an account stops its leads
UpdateSettingsnot IN_PROGRESS/FINISHEDSCHEDULED → reverts to DRAFT
StartCampaignDRAFT onlyneeds sequence + ≥1 account + lead list
AddLeadsToCampaignV2launched ≥ onceadds the lead but does NOT flip a manually PAUSED campaign back to IN_PROGRESS (verified, see §10)

Editing an IN_PROGRESS or FINISHED campaign's sequence/schedule returns an "Invalid campaign status" error. You must Pause first (§10).


6. Schedule / timezone

"schedule": {                          // CampaignScheduleApiDto
  "startDate": "2026-06-15", "endDate": null,   // yyyy-MM-dd, optional
  "dailyStartTime": "09:00:00", "dailyEndTime": "17:00:00",
  "timeZoneId": "America/New_York",    // ⭐ ALWAYS set this (Eastern). IANA id.
  "enabledMonday": true, "enabledTuesday": true, "enabledWednesday": true,
  "enabledThursday": true, "enabledFriday": true,
  "enabledSaturday": false, "enabledSunday": false }
  • ⭐ ALWAYS pass timeZoneId: "America/New_York" (US Eastern, ET/EDT/EST) unless explicitly told otherwise. If you omit timeZoneId (or omit the whole schedule), HeyReach defaults to Etc/GMT = London/GMT, which is almost never what we want and silently ships outreach at the wrong hours. This bites on both create_campaign and UpdateSchedule, set it every time. (America/New_York auto-handles EDT/EST; the UI shows it as "New York (Eastern Standard Time)".)
  • Omitted schedule on Create → defaults to Mon-Fri 09:00-17:00 GMT (London), set the schedule explicitly.
  • UpdateSchedule only on DRAFT/SCHEDULED/PAUSED (a running campaign rejects it, this is the classic "Invalid campaign status"). Use it to fix an existing campaign's timezone if it was created as GMT (Pause → UpdateSchedule with America/New_York → Resume).
  • ⚠ The schedule/timezone is NOT readable via the API, GetById returns it null and GetCampaignSequence only has the node tree. You can't audit a campaign's current timezone programmatically (UI only); so you either trust it was set right at create, or blind-overwrite it via UpdateSchedule.
  • After a campaign starts, startDate and linkedInUserListId lock.

7. Senders & tags

Senders: list with POST /li_account/GetAll ({offset,limit}; returns id, firstName, lastName, emailAddress). Assign on Create via linkedInAccountIds (1-100). Change later with UpdateAccounts (full replacement; on PAUSED, removing a sender stops its assigned leads). On a campaign object the assigned senders read back as campaignAccountIds. Per-sender daily limits are set in-app, not via API.

Tags (strings, not IDs): verified paths POST /lead/GetTags, POST /lead/AddTags, POST /lead/ReplaceTags. Lead identified by profileUrl XOR leadLinkedInId (exactly one, passing neither → 400 "One of the following parameters should be provided"). Note these are under /lead/* (the /lead/GetTagsForLead, /tag/* variants 404).

  • Get tags: {profileUrl} → {tags:[...]}.
  • Add tags: {profileUrl, tags:["interested"], createTagIfNotExisting:true} (AddTags defaults true → auto-creates).
  • Replace tags: replaces ALL; createTagIfNotExisting defaults false → unknown tag = 400.

8. Webhooks

⚠ REST path unconfirmed. None of the obvious webhook/* paths resolved by probing (all 404). The operations are real via the MCP *_webhook tools; for raw HTTP, open the Postman collection in-browser to get the literal path. Payload/enum below are from the MCP create_webhook schema.

POST <webhook-create-path>
{ "webhookName": "reply-listener",    // 3-25 chars
  "webhookUrl": "https://...", "eventType": "MESSAGE_REPLY_RECEIVED",
  "campaignIds": [12345] }            // null/empty = all campaigns

Event enum: CONNECTION_REQUEST_SENT, CONNECTION_REQUEST_ACCEPTED, MESSAGE_SENT, MESSAGE_REPLY_RECEIVED, EVERY_MESSAGE_REPLY_RECEIVED, INMAIL_SENT, INMAIL_REPLY_RECEIVED, FOLLOW_SENT, LIKED_POST, VIEWED_PROFILE, CAMPAIGN_COMPLETED, LEAD_TAG_UPDATED, LEAD_FINISHED_SEQUENCE_WITHOUT_REPLYING, LEAD_AUTO_TAGGED_INTERESTED, LEAD_AUTO_TAGGED_NOT_INTERESTED, LEAD_AUTO_TAGGED_GENERIC.


9. API vs MCP: when to use which

NeedUseWhy
Bulk lead load, anything scripted/loopedAPIFaster, cleaner, cheaper; MCP is slow on bulk
Quick read (list campaigns, get a sequence, check stats)MCP okConvenient
One-time load of thousands of leadsUI CSV importNo 100-cap; one action beats ~13 calls
Campaign create / edit / start (one-off, conversational)MCP now fineFull lifecycle is MCP-exposed (below)

Always pick the MCP server bound to the workspace you're operating in (see §0 rule 4).

Campaign-lifecycle MCP tools (added 2026-06-10; officially announced by HeyReach)

The HeyReach MCP now exposes the full campaign lifecycle (previously you needed the raw API for these), now confirmed by HeyReach's own build-and-launch-end-to-end announcement. The vendor's framing is a single-prompt build-to-launch (e.g. "Clone my best-performing campaign, swap in this lead list, assign my three warmest senders, set it to send 9-5 on weekdays, and launch it"). Each tool maps 1:1 to an API endpoint documented above, same payloads, same status-gating, same gotchas:

MCP toolAPI endpointNotes
create_campaigncampaign/Createfully-configured draft (list + accounts + schedule + sequence)
create_campaign_from_templatecampaign/CreateCampaignFromTemplateclone an existing campaign
update_campaign_sequencecampaign/UpdateSequencecreate/replace the workflow tree (DRAFT/SCHEDULED/PAUSED)
update_campaign_settingscampaign/UpdateSettingsname, lead list, exclusions
update_campaign_schedulecampaign/UpdateSchedulewindow, days, timezone
update_campaign_accountscampaign/UpdateAccountsreplace assigned LinkedIn senders
get_campaign_sequencecampaign/GetCampaignSequencefetch the workflow tree
start_campaigncampaign/StartCampaignactivate a DRAFT

So for a quick one-off "build/clone/edit/launch a campaign" you can stay in MCP; the delay rules (§5.2), node payloads/sequencing (§5.3), status-gating (§5.4), and the required-variable guard (§3.1) all still apply, the MCP doesn't validate copy/variables for you. Still no MCP (or API) delete, UI only.


10. Workarounds / hacky moves

  • Edit a running (IN_PROGRESS) campaign: you can't directly → Pause → UpdateSequence/Schedule/Accounts → Resume. Pausing unlocks the edit gates. (Resume is the reliable way to restart a paused campaign, see next.)
  • ⚠ Adding leads does NOT auto-resume a PAUSED campaign (verified live). I tested it directly: a campaign in PAUSED stayed PAUSED after AddLeadsToCampaignV2 (added the lead, status unchanged). So the common "just add a dummy lead to wake it up" claim is false for a manually-paused campaign, use Resume to restart it. Correct flow to modify-and-restart: Pause → UpdateSequence/Schedule → (add any new leads) → Resume.
  • Restart a FINISHED campaign: whether adding leads auto-reactivates a FINISHED campaign is unverified (couldn't produce a FINISHED campaign without real sends to test). Don't rely on it, assume you need Resume/StartCampaign, or clone via CreateCampaignFromTemplate and run fresh.
  • Launch a DRAFT: use StartCampaign, not Resume (Resume rejects DRAFT with a misleading error).
  • Clone instead of edit-in-place: CreateFromTemplate copies the sequence+schedule into a fresh DRAFT you can freely modify, then Start.
  • Blank connection request (no note, higher accept rates): payload.messages = [].
  • Clean capitalization: never trust {FIRST_NAME}/{COMPANY_NAME}; populate and use {FNAME}/{CNAME} (§3).
  • Get a guaranteed-valid sequence tree: GetCampaignSequence on a good campaign, tweak the message text, POST to UpdateSequence/Create.
  • No campaign delete AND no list delete via API: list/DeleteList, list/Delete, list/RemoveList all 404 (verified). Dead campaigns and dead lists are cleaned up only in the UI. (You can empty a list via the DELETE DeleteLeadsFromListByProfileUrl, see §4 for the method/field gotcha, but the list shell stays.)

Validation order & enforcement points (verified live)

campaign/Create validates in this order: (1) request schema (e.g. name ≤50, actionDelay 0-100 → 400) → (2) list existence ("The list does not exist!") → (3) sequence structure (node placement, payloads). All three are enforced at create once the list is valid.

  • ⚠ Read-vs-write asymmetry on delays: GetCampaignSequence reads back server-added END nodes as actionDelay: 0, "HOUR", but you cannot POST 0 or any sub-3-hour HOUR value, the live floor is 3 hours on every node, END included (verified 2026-06-12: 1 HOUR END → 400 "delay must be at least 3 hours…"). Author every node with actionDelay ≥ 3 (HOUR) or ≥ 1 DAY. If you round-trip a fetched sequence, bump any 0- or sub-3h-HOUR nodes before re-posting. (Real numeric cap is 0-100, not "500 days", see §5.2.)

Other verified gotchas

  • GetById 404s on a just-created campaign that GetAll lists fine ("There is no campaign with provided id." while GetAll shows it with a real status). Propagation lag, read a fresh campaign's status via GetAll, or retry GetById after a few seconds.
  • AddLeads accepts well-formed but non-existent profileUrls (stored, addedLeadsCount:1, no existence check at add time), garbage URLs enter the list silently; validate before loading.

Verified response shapes (live)

  • GET /campaign/GetById?campaignId= → {id, name, creationTime, linkedInUserListName, linkedInUserListId, campaignAccountIds, status, progressStats, excludeInOtherCampaigns, excludeHasOtherAccConversations, excludeContactedFromSenderInOtherCampaign, excludeListId, organizationUnitId, startedAt}. ⚠ Note: the schedule is not returned here (read it where you set it; GetById omits it).
  • POST /list/GetLeadsFromList lead object → {id, linkedin_id, profileUrl, firstName, lastName, headline, imageUrl, location, companyName, companyUrl, position, about, connections, followers, tags, autoTags, emailAddress, enrichedEmailAddress, customEmailAddress, customFields}. (Note: reads use linkedin_id/snake fields; the add-leads input uses profileId/camel fields, they're not symmetric.)
  • POST /inbox/GetConversationsV2 {filters:{}, offset, limit} → {totalCount, items:[{id, read, groupChat, blockedByMe, blockedByParticipant, lastMessageAt, lastMessageText, lastMessageType, lastMessageSender, totalMessages, linkedInAccountId, correspondentProfile}]}.
  • POST /stats/GetOverallStats {accountIds:[], campaignIds:[]} → {byDayStats, overallStats} (empty arrays = whole workspace). overallStats keys: profileViews, postLikes, follows, messagesSent, totalMessageStarted, totalMessageReplies, inmailMessagesSent, totalInmailStarted, totalInmailReplies, connectionsSent, connectionsAccepted, uniqueLeadsContacted, autoTaggedInterested, totalAutoTagged, messageReplyRate, inMailReplyRate, connectionAcceptanceRate, autoTaggedInterestedRate.
  • POST /lead/GetTags {profileUrl} → {tags:[...]}.

11. Files

  • Build a small heyreach.py client with all gotchas + validate_campaign() + preflight() baked in (prefer this over hand-rolling).
  • references/recipes.md, verbatim working payloads (auth helper, build-sequence-tree tail-first with fallbacks + custom fields, create campaign, batch lead load in 100s, update sequence, start, pause-edit-resume).
  • Cross-refs: instantly-copywriter (copy standards), the provider notes (provider quick notes), instantly-api (the email sibling).

Files bundled with it

These load only when the skill asks for them, so they cost nothing until it runs.

Other skills for the same job

Different authors, same problem. Matched on the words in the skill name, across every library in the catalogue except this one.

Need help setting it up?

This page tells you what heyreach-api does and what it needs. Cheetah builds the agent setup it runs inside: data, CRM, sequencing and the guardrails.

Book a call →

The directory stays free. There is nothing gated behind this.