Systems Lab

Agent skill

heyreach-campaign-creator

Create and manage LinkedIn outreach campaigns in Heyreach via the Campaign API.

activeNeeds a keyActs undeclared3,845 words

Filed under Outbound email.

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

What it does when it runs

Create and manage LinkedIn outreach campaigns in Heyreach via the Campaign API. Use this skill whenever the user wants to: push LinkedIn sequences to Heyreach, create Heyreach campaigns, build LinkedIn outreach campaigns, set up Heyreach sequences, update Heyreach campaign settings/schedule/accounts, or manage Heyreach campaigns programmatically. Also trigger when the user mentions Heyreach campaign creation, LinkedIn automation setup, or wants to push copy from copy.json to Heyreach. Even casual requests like 'push the LinkedIn copy to Heyreach' or 'create the Heyreach campaigns' should use this skill.

Automated analysis of the skill and the 3 files 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
  • API_KEY
Hosts it reaches
  • api.heyreach.io
Tool permissions it declares
No allowed-tools in the frontmatter. It does act, so it runs under whatever permissions your session already grants.
Actions present in the files
shellwrites filesnetwork

Ask about heyreach-campaign-creator

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-campaign-creator"
mkdir -p ~/.claude/skills/heyreach-campaign-creator
cp -R "/tmp/gtm-skills/skills/heyreach-campaign-creator/." ~/.claude/skills/heyreach-campaign-creator/

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 API_KEY, which you have to obtain separately.

Reproduced in full from OneGTM/gtm-skills/blob/36da828338f8062c90f6a4a8ea8b918e4160f1e5/skills/heyreach-campaign-creator/SKILL.md, which is licensed MIT (repository). 3,845 words, 66 headings.

Heyreach Campaign Creator

This skill creates and manages LinkedIn outreach campaigns in Heyreach using the Campaign API (released April 2026). It reads LinkedIn copy from copy.json (your LinkedIn copy, one entry per step) and pushes fully configured campaigns to Heyreach - complete with sequences, sender accounts, schedules, and exclusion rules.

The Heyreach MCP connector does NOT include campaign creation tools. This skill calls the Campaign API directly via HTTP requests using the user's API key.


What you need before starting

  1. Heyreach API key - found in Heyreach app under Settings -> API. Ask the user for it if not already known.
  2. copy.json - the outbound copy file with LinkedIn sequences (each signal has a linkedin array with connection request + DM steps)
  3. Client name - used for naming campaigns and lead lists

Step 1 - Discover the workspace

Before creating anything, probe the workspace to understand what's available.

import requests

API_KEY = "<user's key>"
BASE_URL = "https://api.heyreach.io/api/public"
HEADERS = {"X-API-KEY": API_KEY, "Content-Type": "application/json"}

# Verify API key
resp = requests.get(f"{BASE_URL}/auth/CheckApiKey", headers=HEADERS)
# 200 = valid

# Get existing campaigns (reveals sender account IDs + duplicate check)
resp = requests.post(f"{BASE_URL}/campaign/GetAll", headers=HEADERS,
                     json={"limit": 100, "offset": 0})
# Extract unique campaignAccountIds from all campaigns
# Also check for existing campaigns with the same client name prefix

# Get existing lists
resp = requests.post(f"{BASE_URL}/list/GetAll", headers=HEADERS,
                     json={"limit": 100, "offset": 0})

Important: The /linkedinaccount/GetAll endpoint returns 404. To find available LinkedIn sender accounts, extract campaignAccountIds from existing campaigns. If no campaigns exist, the user must provide account IDs manually (from Heyreach UI -> Settings -> LinkedIn Accounts).


Step 2 - Pre-flight validation

Before making any create calls, validate everything upfront to catch problems early.

Check for duplicates

# Search for campaigns matching client name
resp = requests.post(f"{BASE_URL}/campaign/GetAll", headers=HEADERS,
                     json={"keyword": client_name, "limit": 100, "offset": 0})
existing = {}
for item in resp.json().get("items", []):
    name = item.get("name", "")
    if name.startswith(client_name):
        existing[name] = {"id": item["id"], "status": item["status"]}
# Skip any signal whose campaign name already exists

Validate message lengths

# Character limits enforced by the API:
CR_NOTE_MAX = 300       # Connection request note
MESSAGE_MAX = 8000      # DM message body
CAMPAIGN_NAME_MAX = 50  # Campaign name
WITHDRAW_DAYS_MIN = 14  # Minimum auto-withdraw period

# Check every message BEFORE pushing
for step in linkedin_steps:
    msg = convert_variables("\n".join(step["lines"]).strip())
    if "connection request" in step["step"].lower() and len(msg) > 300:
        print(f"WARNING: CR note is {len(msg)} chars (max 300)")
    if "dm" in step["step"].lower() and len(msg) > 8000:
        print(f"WARNING: DM is {len(msg)} chars (max 8000)")

Validate copy.json structure

Ensure every signal has: number, name, and a linkedin array where each step has step (label) and lines (array of strings). Missing fields cause confusing errors downstream.


Step 3 - Create lead lists

Create one empty lead list per signal campaign. Leads get added later (either manually or via API).

resp = requests.post(f"{BASE_URL}/list/CreateEmptyList", headers=HEADERS,
                     json={"name": "ClientName - Signal Name", "listType": "USER_LIST"})
list_id = resp.json()["id"]

The endpoint is list/CreateEmptyList. Not list/Create or list/CreateEmpty - both return 404.

Field names matter: The direct API uses name and listType (not listName which the MCP connector uses). The MCP connector maps field names internally.

CRITICAL - Do NOT use the MCP connector to create lists if you'll reference them in direct API campaign creation. The MCP connector may operate in a different workspace, and lists created there will be invisible to your API key. campaign/Create will return "The list does not exist!" even though the list was just created via MCP. Always use the direct API for list creation.

On failure: If list creation succeeds but campaign creation fails later, you'll have an orphaned list. Note it for manual cleanup - there is no list delete endpoint in the API.


Step 4 - Build and push campaigns

Variable conversion (CRITICAL - do this first)

Heyreach uses a different variable format than copy.json. You MUST convert every variable before pushing. The API does NOT validate variable names - if you push {{firstName}} instead of {FIRST_NAME}, the literal text {{firstName}} will be sent to every lead. This has happened in production and is painful to fix across multiple campaigns.

Convert before pushing:

copy.json formatHeyreach format
{{firstName}}{FIRST_NAME}
{{companyName}}{COMPANY}
{{lastName}}{LAST_NAME}
{{position}}{POSITION}
Custom vars like {{newTitle}}{NEW_TITLE} (uppercase, underscored)

For any remaining {{camelCase}} variables not in the known mapping, convert automatically:

import re
def to_upper_snake(match):
    name = match.group(1)
    s = re.sub(r'([a-z])([A-Z])', r'\1_\2', name)
    return "{" + s.upper() + "}"
text = re.sub(r"\{\{(\w+)\}\}", to_upper_snake, text)

Warning: The API does NOT validate variable names. If you use {NONEXISTENT}, it will send the literal text to the lead. Always confirm available variables in the Heyreach workspace.

Fallback messages

When a MESSAGE node's copy contains personalization variables, you MUST provide a fallbackMessage - a version with variables stripped or replaced with generic text. Without this, the API returns: "The fallback message is invalid in a Send Message action".

This also applies to CONNECTION_REQUEST nodes if they have a note with variables - but since we always send empty CRs (no note), this won't come up in practice.

def strip_vars(text):
    """Create fallback by removing personalization variables"""
    replacements = {
        "{FIRST_NAME}": "", "{COMPANY}": "your company",
        "{NEW_TITLE}": "your new role", "{FIRM_NAME}": "your firm",
        "{LAST_NAME}": "", "{POSITION}": "your role",
    }
    r = text
    for var, repl in replacements.items():
        r = r.replace(var, repl)
    r = re.sub(r"\{[A-Z_]+\}", "", r)  # Catch remaining
    r = re.sub(r"  +", " ", r)
    r = re.sub(r" ,", ",", r)
    return "\n".join(l.strip() for l in r.split("\n")).strip()

Sequence tree structure

Heyreach campaigns use a tree of nodes. The START node is implicit - do not include it. The standard LinkedIn sequence is:

CONNECTION_REQUEST
  -> conditionalNode (accepted): MESSAGE (DM1) -> MESSAGE (DM2) -> MESSAGE (DM3) -> END
  -> unconditionalNode (not accepted): END

Critical rules:

  • Connection requests are always empty (no note). The CR payload should have "messages": []. Empty CRs have higher accept rates. Any CR copy in copy.json is ignored by the push script - the note is never sent.
  • Every path must terminate with an END node
  • END nodes need actionDelay >= 3 hours (the API rejects delay: 0 on any node)
  • CONNECTION_REQUEST and CHECK_IS_CONNECTION nodes need BOTH conditionalNode and unconditionalNode
  • MESSAGE nodes need only unconditionalNode (no conditionalNode)
  • actionDelayUnit is either "HOUR" or "DAY"
  • Every MESSAGE node must have at least one non-empty message (empty messages cause leads to stall)

Enriched sequences (optional)

For more sophisticated sequences, you can add VIEW_PROFILE and FOLLOW nodes before the connection request:

VIEW_PROFILE -> FOLLOW -> CONNECTION_REQUEST -> ...

Or insert a VIEW_PROFILE between DMs for a more natural cadence:

CR -> DM1 -> VIEW_PROFILE -> DM2 -> DM3 -> END

Node types that don't need payloads: VIEW_PROFILE, FOLLOW. They only need nodeType, actionDelay, actionDelayUnit, and unconditionalNode.

For cross-platform sequences, SEND_LEAD_TO_INSTANTLY can hand off a lead to an Instantly email campaign after the LinkedIn sequence.

Building the sequence

def build_sequence(linkedin_steps, dm1_delay=3, dm1_delay_unit="HOUR",
                   dm2_delay=5, dm3_delay=5, withdraw_days=21):
    """Build a Heyreach sequence tree from copy.json LinkedIn steps"""
    dm_msgs = {"cr": [], "dm1": [], "dm2": [], "dm3": []}
    for step in linkedin_steps:
        label = step["step"].lower()
        msg = convert_variables("\n".join(step["lines"]).strip())
        if "connection request" in label:
            if msg: dm_msgs["cr"] = [msg]
        elif "dm 1" in label:
            if msg: dm_msgs["dm1"] = [msg]
        elif "dm 2" in label:
            if msg: dm_msgs["dm2"] = [msg]
        elif "dm 3" in label:
            if msg: dm_msgs["dm3"] = [msg]

    # Warn about empty DM steps
    for key in ["dm1", "dm2", "dm3"]:
        if not dm_msgs[key]:
            print(f"WARNING: {key.upper()} has no message - leads will stall at this node")

    end_node = {"nodeType": "END", "actionDelay": 1, "actionDelayUnit": "DAY"}

    dm3 = {"nodeType": "MESSAGE", "actionDelay": dm3_delay, "actionDelayUnit": "DAY",
           "payload": msg_payload(dm_msgs["dm3"]),
           "unconditionalNode": {"nodeType": "END", "actionDelay": 1, "actionDelayUnit": "DAY"}}
    dm2 = {"nodeType": "MESSAGE", "actionDelay": dm2_delay, "actionDelayUnit": "DAY",
           "payload": msg_payload(dm_msgs["dm2"]), "unconditionalNode": dm3}
    dm1 = {"nodeType": "MESSAGE", "actionDelay": dm1_delay, "actionDelayUnit": dm1_delay_unit,
           "payload": msg_payload(dm_msgs["dm1"]), "unconditionalNode": dm2}

    # Always empty CR note - higher accept rates, no note needed
    cr_payload = {"messages": [], "toBeWithdrawnAfterDays": withdraw_days}

    return {"nodeType": "CONNECTION_REQUEST", "actionDelay": 0, "actionDelayUnit": "DAY",
            "payload": cr_payload, "conditionalNode": dm1, "unconditionalNode": end_node}

Creating the campaign

resp = requests.post(f"{BASE_URL}/campaign/Create", headers=HEADERS, json={
    "name": "ClientName - Signal Name",
    "linkedInUserListId": list_id,
    "LinkedInAccountIds": sender_account_ids,  # PascalCase! Required field.
    "excludeContactedFromSenderInOtherCampaign": True,
    "sequence": sequence_tree
})
campaign_id = resp.json()["campaignId"]
# Campaign is created in DRAFT status

Required fields: name, linkedInUserListId, and LinkedInAccountIds are all required. Omitting LinkedInAccountIds returns "The LinkedInAccountIds field is required." Note the PascalCase capitalization - it's LinkedInAccountIds, not linkedInAccountIds.

CRITICAL: Immediately check status after creation.

resp = requests.get(f"{BASE_URL}/campaign/GetById?campaignId={campaign_id}", headers=HEADERS)
status = resp.json().get("status")
if status != "DRAFT":
    print(f"ALERT: Campaign is {status} - auto-finish bug detected!")
    # Must create a replacement campaign - see Edge Cases section

Note: The Create endpoint does NOT accept a schedule field. Set the schedule separately via UpdateSchedule.


Step 5 - Configure campaign settings

After creation, apply schedule and settings updates. These can only be done on DRAFT, SCHEDULED, or PAUSED campaigns.

Set the schedule

requests.post(f"{BASE_URL}/campaign/UpdateSchedule", headers=HEADERS, json={
    "campaignId": campaign_id,
    "schedule": {
        "dailyStartTime": "09:00:00",
        "dailyEndTime": "19:00:00",
        "timeZoneId": "America/New_York",
        "enabledMonday": True, "enabledTuesday": True, "enabledWednesday": True,
        "enabledThursday": True, "enabledFriday": True,
        "enabledSaturday": False, "enabledSunday": False
    }
})

Update exclusion settings

requests.post(f"{BASE_URL}/campaign/UpdateSettings", headers=HEADERS, json={
    "campaignId": campaign_id,
    "name": "ClientName - Signal Name",
    "linkedInUserListId": list_id,
    "excludeContactedFromSenderInOtherCampaign": True,
    "excludeContactedFromOtherCampaigns": False,
    "excludeHasOtherAccConversations": False
})

Update sender accounts (if needed later)

requests.post(f"{BASE_URL}/campaign/UpdateAccounts", headers=HEADERS, json={
    "campaignId": campaign_id,
    "linkedInAccountIds": [11111, 22222]  # Full replacement, not merge
})

Warning: UpdateAccounts is a full replacement. Any account not in the new list is removed. On PAUSED campaigns, leads assigned to removed accounts are stopped permanently. Also, UpdateAccounts has been observed to trigger status changes on some campaigns - always check status afterward.


Step 5b - Update sequence on existing campaigns

To update the sequence on an existing campaign (must be DRAFT, SCHEDULED, or PAUSED - pause an IN_PROGRESS campaign first):

# Pause if needed
requests.post(f"{BASE_URL}/campaign/Pause?campaignId={campaign_id}", headers=HEADERS)

# Update sequence - NOTE: field name MUST be capital "Sequence"
requests.post(f"{BASE_URL}/campaign/UpdateSequence", headers=HEADERS, json={
    "campaignId": campaign_id,
    "Sequence": sequence_tree  # Capital S is REQUIRED
})

# Resume
requests.post(f"{BASE_URL}/campaign/Resume?campaignId={campaign_id}", headers=HEADERS)

The field name must be "Sequence" with a capital S. Using lowercase "sequence", "rootNode", or "sequenceObject" all return 400: "The Sequence field is required."


Step 6 - Verify

After creating all campaigns, verify each one by fetching it back:

# Get campaign details
resp = requests.get(f"{BASE_URL}/campaign/GetById?campaignId={cid}", headers=HEADERS)
camp = resp.json()
# Check: status (should be DRAFT), campaignAccountIds, exclusion settings

# Get sequence
resp = requests.get(f"{BASE_URL}/campaign/GetCampaignSequence?campaignId={cid}", headers=HEADERS)
seq = resp.json()
# Walk the tree to verify:
#   - Root is CONNECTION_REQUEST
#   - conditionalNode chain has 3 MESSAGE nodes
#   - Every MESSAGE has non-empty messages array
#   - Every MESSAGE with variables has a fallbackMessage
#   - Both paths terminate with END nodes

Verification checklist:

  • Status is DRAFT
  • Correct sender accounts assigned
  • Exclusion settings match
  • Sequence has CONNECTION_REQUEST root
  • 3 DM nodes on accepted path
  • All messages non-empty
  • Fallbacks present where variables exist
  • Schedule set correctly (check via GetById - schedule info is in the response)

Step 7 - Launch campaigns

Once verified, start the campaigns so they're ready to process leads. Campaigns must be in IN_PROGRESS status to accept and process leads added to their lists.

The endpoint is StartCampaign, not Start or Activate. This is undocumented.

for cid in campaign_ids:
    resp = requests.post(
        f"{BASE_URL}/campaign/StartCampaign?campaignId={cid}",
        headers=HEADERS
    )
    if resp.status_code == 200:
        # Wait and verify - empty lists can cause auto-finish
        time.sleep(3)
        resp2 = requests.get(
            f"{BASE_URL}/campaign/GetById?campaignId={cid}",
            headers=HEADERS
        )
        status = resp2.json().get("status")
        if status == "FINISHED":
            print(f"Campaign {cid} auto-finished! Creating replacement...")
            # See Edge Cases -> Auto-finish recovery
        else:
            print(f"Campaign {cid}: {status}")  # Should be IN_PROGRESS

Key facts about StartCampaign:

  • Uses query parameter ?campaignId=, NOT JSON body (same pattern as Pause/Resume)
  • Only works on DRAFT and SCHEDULED campaigns
  • Resume does NOT work on DRAFT campaigns - it only works for PAUSED/FINISHED/FAILED
  • Campaigns with empty lead lists may auto-finish within seconds of starting
  • After starting, always wait a few seconds and verify status

Auto-finish recovery on start: If a campaign auto-finishes because its list is empty, create a replacement (new list + new campaign + re-apply settings + start again). The replacement usually survives because the race condition doesn't trigger every time. See the Edge Cases section for the full recovery workflow.

Campaign lifecycle:

DRAFT -> StartCampaign -> IN_PROGRESS (ready for leads)
IN_PROGRESS -> Pause -> PAUSED
PAUSED -> Resume -> IN_PROGRESS
DRAFT -> StartCampaign -> FINISHED (empty list bug)

Copy rules for multi-sender campaigns

When a campaign has multiple LinkedIn sender accounts, the copy must work for ANY sender - not just one specific person. This is easy to miss and creates awkward messages when a junior rep sends copy written for the CEO.

What to flag vs what's OK

The rule is about identity declarations - language that only makes sense if the sender is a specific person (the founder, the CEO, a team lead). Personal notes that any sender could write are fine.

FLAG (identity/role declaration)OK (personal note any sender can write)
"I run an AI consulting firm""my last note"
"I founded Acme""I'm following up"
"I lead the team""from me" / "last note from me"
"I'm the CRO at...""I'd be happy to"
"my firm" / "my company""I noticed" / "I saw"
"I head up sales""just bumping this"
"I built this product""I wanted to reach out"

The test: Could a junior SDR send this without it being weird? "My last note" - yes. "I founded the company" - no.

Validation

Before pushing any copy, scan all LinkedIn messages for identity/role declarations:

import re

# Only flag identity declarations - NOT personal notes like "my last note" or "I'm following up"
SENDER_IDENTITY_PATTERNS = [
    r'\bI run\b', r'\bI founded\b', r'\bI lead\b', r'\bI built\b',
    r'\bI started\b', r'\bI manage\b', r'\bI oversee\b', r'\bI head\b',
    r"\bI'm the\b",  # "I'm the founder/CEO/CRO"
    r'\bmy firm\b', r'\bmy company\b',
]

for step in linkedin_steps:
    text = ' '.join(step['lines'])
    for pat in SENDER_IDENTITY_PATTERNS:
        if re.search(pat, text, re.IGNORECASE):
            print(f"WARNING: Sender identity language in {step['step']}: {pat}")

What NOT to flag: "I'm following up", "my last note", "from me", "I'd be happy to", "I noticed", "I wanted to reach out" - these are personal notes any sender can write.

Auto-fix mappings

SENDER_FIXES = {
    "I run ": "we run ",
    "I lead ": "we lead ",
    "I founded ": "we founded ",
    "I built ": "we built ",
    "my firm": "our firm",
    "my company": "our company",
}

for old, new in SENDER_FIXES.items():
    text = text.replace(old, new)

Signal-specific guidance: Website Visitors

Website visitor campaigns come in two flavors based on the intent data source:

Website Visitors (Direct)

The lead personally visited your website. They are aware of the brand and likely evaluating options.

Key rules:

  • Do NOT call out the visit ("Saw you on our site" is creepy on LinkedIn)
  • The lead is warm - they already know who you are
  • Lead with the value prop directly, more confidently than cold outbound
  • The warmth comes from timing, not from referencing the visit

Example DM1:

Hey {FIRST_NAME} - we help PE-backed SaaS companies ship AI to production
in about 8 weeks. We've built for Google, OpenAI, Walmart, and across
portfolios for Vista Equity, General Atlantic, and HG Capital.

Curious if {COMPANY} is exploring AI on the product side - happy to share
how we've helped similar companies move fast without building an internal
AI team from scratch.

Website Visitors (Indirect)

Someone at the lead's company visited, but not necessarily this person. The company is likely evaluating AI solutions.

Key rules:

  • Even softer opener than direct - this person may not know about you at all
  • Reference the company's potential interest, not any specific visit
  • Tone is closer to cold outbound but with slightly more confidence (you know the company is interested)
  • Never say "someone at your company" or anything that reveals the intent data source

Example DM1:

Hey {FIRST_NAME} - we're an AI consulting firm that helps PE-backed SaaS
companies go from idea to production AI in about 8 weeks. We've built across
portfolios for Vista Equity, General Atlantic, and HG Capital.

Is {COMPANY} thinking about where AI fits into the product? Happy to share
how we've helped similar companies figure that out.

Shared patterns for both

  • DM2: Offer a content asset (report, guide) as a value-add nudge
  • DM3: Graceful exit, team voice ("Last note from us... we're here")
  • Connection request: Empty (no note) - same as all campaigns
  • All copy uses "we/us/our" - no sender identity declarations

Edge Cases and Recovery

Auto-finish recovery

If a campaign auto-finishes (status goes to FINISHED/IN_PROGRESS immediately after creation):

  1. Note the dead campaign ID and list ID for cleanup
  2. Create a new lead list with the same name
  3. Create a new campaign pointing to the new list
  4. Re-apply all settings (schedule, exclusions, accounts)
  5. Verify the replacement is in DRAFT status
  6. Log both the dead and replacement IDs in results
# Example recovery flow
if status != "DRAFT":
    print(f"Campaign {cid} auto-finished to {status}. Creating replacement...")
    # New list
    resp = requests.post(f"{BASE_URL}/list/CreateEmptyList", headers=HEADERS,
                         json={"name": campaign_name, "listType": "USER_LIST"})
    new_list_id = resp.json()["id"]
    # New campaign
    resp = requests.post(f"{BASE_URL}/campaign/Create", headers=HEADERS, json={
        "name": campaign_name, "linkedInUserListId": new_list_id,
        "linkedInAccountIds": sender_ids,
        "excludeContactedFromSenderInOtherCampaign": True,
        "sequence": sequence
    })
    new_cid = resp.json()["campaignId"]
    # Re-apply schedule and settings...

Partial failure cleanup

When a batch of campaigns partially fails:

  • Lists are created before campaigns. If campaign creation fails, the list is orphaned.
  • The API has no list delete endpoint. Orphaned lists must be cleaned up in the Heyreach UI.
  • Track orphaned list IDs in the results file so the user knows what to clean up.
  • The push script logs all orphaned resources automatically.

Duplicate handling

If running the script a second time (e.g., after fixing a bug):

  • The script checks for existing campaigns with matching names
  • Existing campaigns are skipped and logged as "EXISTING"
  • If you need to recreate a campaign that already exists, you must either:
    • Delete/rename the existing one in the Heyreach UI first
    • Use a different client name prefix

Stale campaigns from testing

During development, you may accumulate test campaigns and lists. Keep a heyreach_results.json file as a ledger of everything created. When it's time to clean up:

  1. Open the Heyreach UI
  2. Find campaigns matching your test client name
  3. Delete or archive them manually
  4. Delete the associated lead lists

Helper script

The scripts/push_to_heyreach.py script automates the full workflow. Key features:

  • Dry-run mode (--dry-run): Preview what would be created without making API calls
  • Duplicate detection: Skips signals that already have campaigns
  • Auto-discovery: Finds sender accounts from existing campaigns
  • Validation: Checks message lengths, campaign name length, copy.json structure
  • Status checks: Catches the auto-finish bug immediately after creation
  • Verification pass: Fetches every campaign back and validates structure
  • Logging: Dual logging to console (INFO) and file (DEBUG) with timestamps
  • Results file: Saves full results including failures and orphaned resources
# Preview first
python3 push_to_heyreach.py copy.json API_KEY ClientName --dry-run

# Then push for real
python3 push_to_heyreach.py copy.json API_KEY ClientName \
    --sender-ids 11111, 22222 \
    --dm1-delay 3 --dm1-delay-unit HOUR \
    --dm2-delay 5 --dm3-delay 5 \
    --withdraw-days 21 \
    --timezone America/New_York \
    --start-hour 9 --end-hour 19

Gotchas and lessons learned

Read references/api-gotchas.md for the full list of API quirks discovered through trial and error. The critical ones:

  1. MCP field names != API field names. The MCP connector maps listName to name internally. When calling the API directly, use the raw field names.
  2. MCP workspace mismatch. The Heyreach MCP connector may operate in a different workspace than your API key. Lists created via MCP will be invisible to direct API calls. Always create lists via the direct API when you'll use them in campaign creation.
  3. LinkedIn account endpoint is 404. Use campaign data to discover account IDs.
  4. END nodes need delays. Any node with actionDelay: 0 will be rejected. Set END nodes to at least {"actionDelay": 1, "actionDelayUnit": "DAY"}.
  5. Campaigns can auto-finish. If a campaign has an empty lead list and gets started, it may immediately move to FINISHED status. FINISHED campaigns cannot be edited - you must create a replacement.
  6. Fallback messages are required when any MESSAGE or CONNECTION_REQUEST node's copy contains personalization variables like {FIRST_NAME}.
  7. Create doesn't accept schedule. The schedule must be set via a separate UpdateSchedule call after campaign creation.
  8. campaign/Create requires LinkedInAccountIds (PascalCase). Omitting it returns a required field error. Note the PascalCase - not camelCase like linkedInUserListId.
  9. UpdateSequence requires "Sequence" (capital S). Lowercase sequence, rootNode, sequenceObject all return 400.
  10. GetCampaignSequence is the read endpoint for sequences. Not GetSequence or Sequence (both 404). Sequences are NOT in the GetById response.
  11. List creation endpoint is list/CreateEmptyList. Not list/Create or list/CreateEmpty (both 404).
  12. Rate limit: 300 requests per minute. Add brief pauses between calls when creating multiple campaigns.
  13. Character limits: CR note 300 chars, message 8000 chars, campaign name 50 chars. Check before pushing.
  14. No delete endpoints. Orphaned lists and campaigns must be cleaned up in the Heyreach UI.
  15. UpdateAccounts can trigger status changes. Always verify status after calling UpdateAccounts.
  16. StartCampaign is the launch endpoint. Not Start, Activate, or Launch - only StartCampaign works. Uses query param like Pause/Resume. Resume does NOT work on DRAFT campaigns.
  17. Starting with empty lists causes auto-finish. Campaigns go IN_PROGRESS -> FINISHED within seconds. The push script handles this automatically with --start by creating replacements.
  18. UpdateSchedule format is fragile. May silently reject schedule on newly created campaigns. Non-critical - default schedule works fine as fallback.
  19. Variable format conversion is essential. copy.json uses {{firstName}} (double curly, camelCase) for Word doc output. Heyreach needs {FIRST_NAME} (single curly, UPPER_SNAKE). Always convert before pushing. The API does NOT validate variable names - wrong format sends literal text to leads.

API endpoint reference

Read references/api-reference.md for the complete endpoint documentation including request/response schemas, error codes, and node type specifications.


Output

After creating all campaigns, report:

  • Campaign IDs, names, and list IDs in a table
  • Status (should all be DRAFT)
  • Sender accounts assigned
  • Schedule configured
  • Any campaigns that failed and why
  • Any orphaned lists/campaigns that need manual cleanup
  • Verification results (pass/fail per campaign)

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-campaign-creator 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.