Agent skill
heyreach-api
Operate HeyReach (LinkedIn outreach) end-to-end via API and MCP.
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-toolsin the frontmatter. It only issues instructions, so there is nothing to bound. - Actions present in the files
- None. Instructions only.
Install it
View source on GitHub ↗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.
The skill
Source on GitHub ↗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-KEYauth,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/UpdateScheduleon 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); badlistIdis a silent 200 no-op (zero counts, no error); no list-delete endpoint exists (all 404);campaign/Createvalidates schema→list-existence→sequence;name>50→400. CapturedGetById/lead-object/GetConversationsV2/GetOverallStatsshapes (§10). Path corrections verified:CreateCampaignFromTemplate(notCreateFromTemplate),li_account/GetById,lead/GetTags|AddTags|ReplaceTags,inbox/SendMessage;CreateEmptyListfield isnamenotlistName. Reactivation claim disproven: adding leads to a manually PAUSED campaign does NOT resume it (stays PAUSED), useResume. 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 (incl0) → 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).GetById404s on a just-created campaign (useGetAll). Delete-leads-from-list isDELETE+ body fieldProfileUrls(POST→405 silent no-op;leadProfileUrls→400), verified.MESSAGEnodes may carry aconditionalNode(not unconditional-only).UpdateSequencesuccess = empty 200 body.AddLeadsToListV2silently drops leads missingfirstName/profileUrl. NB: HeyReach has no Cloudflare UA ban (default Pythonurllib/requestsworks, 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:
| Symptom | Cause | Fix |
|---|---|---|
| Lead-delete "succeeded" but list didn't shrink | POST to delete-leads → 405 no-op | use DELETE + body field ProfileUrls (not leadProfileUrls) |
| Load returns 200 but 0 added | wrong listId → silent 200, zero counts | assert added+updated>0 (the helper raises) |
| Fewer leads loaded than sent | leads missing firstName/profileUrl silently dropped | preflight (§3.1) before load |
| Messages send generic, not personalized | empty custom var → silent fallbackMessage | preflight requires every {FNAME}/{CNAME}/custom token |
| Outreach goes out at wrong hours | omitted timeZoneId → defaults to Etc/GMT (London) | ALWAYS set America/New_York (§6) |
| Paused campaign won't restart after adding leads | adding leads does NOT resume it | use Resume (§10) |
create_campaign 400 / 500 | bad node delay (0/>100/MINUTE), INMAIL empty payload, FOLLOW after CONNECTION_REQUEST | run validate_campaign() (§5) |
| Can't read a campaign's timezone to verify | schedule isn't API-readable (GetById omits it) | check in UI, or blind-set via UpdateSchedule |
GetById 404 on a campaign you just made | propagation lag | read status via GetAll |
Quickest safety net: put every fix above into one small
heyreach.pyclient and import it everywhere.
0. Golden rules
- 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.)
- 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 controlFNAME/CNAMEourselves viacustomUserFields, so they're clean. This is the standard. (§3) - Always supply a
fallbackMessageon every message that uses a variable, it's sent when the lead lacks the variable, so you never deliver a broken{TOKEN}literal. - 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). - 100 leads per add call. Chunk every import in batches of 100.
- Run the required-variable preflight guard before every load (§3.1). Never pipe in a lead missing
profileUrl/firstNameor any custom token ({FNAME}/{CNAME}/…) the copy uses, HeyReach drops or falls-back silently. Upload only leads that pass; surface the rest. - 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>(NOTAuthorization: Bearer).Content-Type: application/jsonon POSTs. - Validate key:
GET /auth/CheckApiKey→ 200 if valid. - Key: HeyReach app → Settings → API. Client key in
.envasHEYREACH_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)
| Area | Operation | Method + path | Notes |
|---|---|---|---|
| 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). |
| Area | Operation | Method + path | Notes |
|---|---|---|---|
| Campaign | Create | ✅ POST /campaign/Create | Lands in DRAFT. Returns {campaignId}. §5 |
| Create from template | ✅ POST /campaign/CreateCampaignFromTemplate | NOT /CreateFromTemplate (404). Body needs LinkedInAcccountIdsForCampaign (triple-c typo). Clones into DRAFT | |
| Get / Get all | ✅ GET /campaign/GetById?campaignId= · ✅ POST /campaign/GetAll | GetById 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/AddLeadsToCampaignV2 | accountLeadPairs[]. ≤100. Does NOT reactivate a PAUSED campaign (verified). §4, §10 | |
| Stop one lead | ✅ POST /campaign/StopLeadInCampaign | Takes lead id or profileUrl | |
| Get leads from campaign | ✅ POST /campaign/GetLeadsFromCampaign | {campaignId,...} | |
| Get campaigns for lead | ✅ POST /campaign/GetCampaignsForLead | needs email / linkedInId / profileUrl | |
| Get sequence | ✅ GET /campaign/GetCampaignSequence?campaignId= | Returns the node tree, reuse it directly | |
| Update sequence | ✅ POST /campaign/UpdateSequence | DRAFT/SCHEDULED/PAUSED only | |
| Update schedule | ✅ POST /campaign/UpdateSchedule | DRAFT/SCHEDULED/PAUSED only (else 400 "Invalid campaign status") | |
| Update accounts | ✅ POST /campaign/UpdateAccounts | needs LinkedInAccountIds; full replace of senders | |
| Update settings | ✅ POST /campaign/UpdateSettings | needs Name; not on IN_PROGRESS/FINISHED | |
| Delete | , | ❌ NO API delete (404). UI only. | |
| Lists | Create 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/GetLeadsFromList | limit ≤1000. Lead-object fields in §10 | |
| Get companies | ✅ POST /list/GetCompaniesFromList | COMPANY_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. | |
| Senders | Get 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 / Tags | Get lead | ✅ POST /lead/GetLead | by profileUrl / linkedInId |
| Get / Add / Replace tags | ✅ POST /lead/GetTags · ✅ POST /lead/AddTags · ✅ POST /lead/ReplaceTags | Tags are strings, not IDs. profileUrl XOR leadLinkedInId. §7 | |
| Inbox | Conversations | ✅ POST /inbox/GetConversationsV2 | shape in §10 |
| Send message | ✅ POST /inbox/SendMessage | {conversationId, linkedInAccountId, message} | |
| Get chatroom | ❓ (MCP get_chatroom) | REST path unconfirmed; use GetConversationsV2 | |
| Stats | Overall | ✅ POST /stats/GetOverallStats | {accountIds:[], campaignIds:[]}; shape in §10 |
| Webhooks | Create | ✅ 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[].nameto 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 →
fallbackMessageis a string. - INMAIL →
fallbackMessageis 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:
- Always:
profileUrl(non-empty, normalized) andfirstName, else the lead is silently dropped at load. - Every custom token used anywhere in the sequence (
{FNAME},{CNAME}, and any others) must have a matching non-empty entry in that lead'scustomUserFields. Extract the tokens from your sequence copy and require all of them. - 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:AccountLeadPairssame cap). Emptyleads:[]→ 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 assertaddedLeadsCount + updatedLeadsCount > 0, or you'll think a load succeeded when it loaded nothing. (Verified live.) - Silently drops leads missing/empty
firstNameORprofileUrl, they don't error, they just don't load (e.g. 615 of 617 added, the 2 short ones vanish). The shortfall only shows asaddedLeadsCount < len(leads), so reconcile counts. NormalizeprofileUrltohttps://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),offsetzero-based.- Remove from a list: ⚠
DELETE /list/DeleteLeadsFromListByProfileUrlwith body{listId, ProfileUrls:[...]}→ 200{notFoundInList:[...]}(lists the URLs that didn't match). Verified gotchas: the POST form returns 405 with an empty body (soif status==200silently no-ops and the list never trims, a costly trap); the field isProfileUrls, notleadProfileUrls(which 400s). Normalize eachprofileUrlto the stored format (https://www.linkedin.com/in/...) first, or it won't match and lands innotFoundInList.
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: trueon 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, andexcludeHasOtherAccConversations= leads already in a conversation with any of your accounts, are optional; enable when the use case calls for it.) Create-from-template (verified pathPOST /campaign/CreateCampaignFromTemplate) clones an existing campaign's sequence+schedule:{campaignIdForTemplate, name, linkedInUserListId, linkedInAcccountIdsForCampaign}(note the API's triple-ctypo 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/branchENDnodes too, not just action nodes (an earlier note here claimed1 HOUR/2 HOURwere accepted, that's wrong on the live UpdateSequence path). Safe practice: author every node, END included, withactionDelay ≥ 3when unit isHOUR, or≥ 1 DAY. (GetCampaignSequencestill reads back server-added END nodes as0 HOUR, but you cannot POST0or 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 viaunconditionalNode.MESSAGE(andINMAIL) may ALSO carry aconditionalNode(the "stop-if-replied" branch, typically pointing atEND), verified accepted onCreateand present in live trees. So it's not strictlyunconditionalNode-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
GetCampaignSequenceon 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):
| Node | Payload | Notes |
|---|---|---|
CONNECTION_REQUEST | {messages[≤300], fallbackMessage, toBeWithdrawnAfterDays≥14}; messages:[] = blank request | branching (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 |
INMAIL | REQUIRED {messages:[{subject≤200, message≤1900}], fallbackMessage:{subject,message}} | ⚠ empty payload → 500 server error (always send a real payload) |
LIKE_POST | optional (defaults above) | |
VIEW_PROFILE, FOLLOW | none | |
SEND_LEAD_TO_INSTANTLY/SMARTLEAD | resource id, validated at create | |
SEND_LEAD_TO_BISON | not validated at create | |
CHECK_IS_CONNECTION (internal IS_CONNECTION), CHECK_IS_OPEN_PROFILE, FIND_EMAIL | none | branching (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.
| Edit | Allowed when | Side effect |
|---|---|---|
| UpdateSequence | DRAFT, SCHEDULED, PAUSED | SCHEDULED → reverts to DRAFT. Success = HTTP 200 with empty body (don't json.parse it); invalid sequence → 400 {errors}. Edits a DRAFT in place. |
| UpdateSchedule | DRAFT, SCHEDULED, PAUSED | SCHEDULED → reverts to DRAFT |
| UpdateAccounts | DRAFT, SCHEDULED, PAUSED | On PAUSED, removing an account stops its leads |
| UpdateSettings | not IN_PROGRESS/FINISHED | SCHEDULED → reverts to DRAFT |
| StartCampaign | DRAFT only | needs sequence + ≥1 account + lead list |
| AddLeadsToCampaignV2 | launched ≥ once | adds 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 omittimeZoneId(or omit the whole schedule), HeyReach defaults toEtc/GMT= London/GMT, which is almost never what we want and silently ships outreach at the wrong hours. This bites on bothcreate_campaignandUpdateSchedule, set it every time. (America/New_Yorkauto-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.
UpdateScheduleonly 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 →UpdateSchedulewithAmerica/New_York→ Resume).- ⚠ The schedule/timezone is NOT readable via the API,
GetByIdreturns itnullandGetCampaignSequenceonly 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 viaUpdateSchedule. - After a campaign starts,
startDateandlinkedInUserListIdlock.
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;
createTagIfNotExistingdefaults 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*_webhooktools; for raw HTTP, open the Postman collection in-browser to get the literal path. Payload/enum below are from the MCPcreate_webhookschema.
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
| Need | Use | Why |
|---|---|---|
| Bulk lead load, anything scripted/looped | API | Faster, cleaner, cheaper; MCP is slow on bulk |
| Quick read (list campaigns, get a sequence, check stats) | MCP ok | Convenient |
| One-time load of thousands of leads | UI CSV import | No 100-cap; one action beats ~13 calls |
| Campaign create / edit / start (one-off, conversational) | MCP now fine | Full 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 tool | API endpoint | Notes |
|---|---|---|
create_campaign | campaign/Create | fully-configured draft (list + accounts + schedule + sequence) |
create_campaign_from_template | campaign/CreateCampaignFromTemplate | clone an existing campaign |
update_campaign_sequence | campaign/UpdateSequence | create/replace the workflow tree (DRAFT/SCHEDULED/PAUSED) |
update_campaign_settings | campaign/UpdateSettings | name, lead list, exclusions |
update_campaign_schedule | campaign/UpdateSchedule | window, days, timezone |
update_campaign_accounts | campaign/UpdateAccounts | replace assigned LinkedIn senders |
get_campaign_sequence | campaign/GetCampaignSequence | fetch the workflow tree |
start_campaign | campaign/StartCampaign | activate 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. (
Resumeis 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
PAUSEDstayedPAUSEDafterAddLeadsToCampaignV2(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, useResumeto 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
FINISHEDcampaign is unverified (couldn't produce a FINISHED campaign without real sends to test). Don't rely on it, assume you needResume/StartCampaign, or clone viaCreateCampaignFromTemplateand run fresh. - Launch a DRAFT: use
StartCampaign, notResume(Resume rejects DRAFT with a misleading error). - Clone instead of edit-in-place:
CreateFromTemplatecopies 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:
GetCampaignSequenceon a good campaign, tweak the message text, POST toUpdateSequence/Create. - No campaign delete AND no list delete via API:
list/DeleteList,list/Delete,list/RemoveListall 404 (verified). Dead campaigns and dead lists are cleaned up only in the UI. (You can empty a list via theDELETEDeleteLeadsFromListByProfileUrl, 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:
GetCampaignSequencereads back server-added END nodes asactionDelay: 0, "HOUR", but you cannot POST0or any sub-3-hour HOUR value, the live floor is 3 hours on every node, END included (verified 2026-06-12:1 HOUREND → 400 "delay must be at least 3 hours…"). Author every node withactionDelay ≥ 3(HOUR) or≥ 1 DAY. If you round-trip a fetched sequence, bump any0- or sub-3h-HOUR nodes before re-posting. (Real numeric cap is 0-100, not "500 days", see §5.2.)
Other verified gotchas
GetById404s on a just-created campaign thatGetAlllists fine ("There is no campaign with provided id."whileGetAllshows it with a real status). Propagation lag, read a fresh campaign's status viaGetAll, or retryGetByIdafter a few seconds.AddLeadsaccepts well-formed but non-existentprofileUrls (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/GetLeadsFromListlead object →{id, linkedin_id, profileUrl, firstName, lastName, headline, imageUrl, location, companyName, companyUrl, position, about, connections, followers, tags, autoTags, emailAddress, enrichedEmailAddress, customEmailAddress, customFields}. (Note: reads uselinkedin_id/snake fields; the add-leads input usesprofileId/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).overallStatskeys: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.pyclient 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.
- prospeo-search-api by growthenginenowoslawski · 739
- smartlead-api by growthenginenowoslawski · 739
- extruct-api by extruct-ai · 109
- gtm-api-linkedin by gtm-api · 89
- ai-api-developer-gtm by 0xF4ng · 6
- ga4-api-reporting by Ad-Superpowers · 5
- prospeo-search-api by timyakubson · 3
- smartlead-api by timyakubson · 3
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.