← toutes les doctrines
growthoto

Find people engaging with competitors' LinkedIn posts

Finds who commented on or reacted to competitors' LinkedIn posts, drops their staff and your CRM contacts, and drafts emails you approve in Slack.

connecteurs utilisés

Llinkedin3
Ddata4
SlackSlack4
ApifyApify6
HubSpotHubSpot2
FullEnrichFullEnrich2
LemlistLemlist7

Find people engaging with competitors' LinkedIn posts

When to use it: someone who comments on a competitor's post about the problem you solve has named that problem in public, this week. Most of a competitor's audience is not that person, though: it is their own staff, their customers, recruiters and people already in your pipeline. Run this weekly against the company pages of the competitors you track; it filters hard enough that what survives is worth an email, and a person approves every name before anything sends.

              Scheduled routine, weekly
              "Find who engaged with our competitors' posts in the last two weeks."
                         │
                         ▼
╔═════════════════════════════════════════════════╗
║  0 · Check the session and the watch list       ║   linkedin_unipile_account, apify_actor
║  Confirm the LinkedIn session and the pinned    ║   hubspot_property; every check here is free
║  actor, load the watch list, test the CRM key.  ║   an expired session looks thin, not broken
╚════════════════════════╤════════════════════════╝
                         ▼
┌─────────────────────────────────────────────────┐
│  1 · Collect the engagement                     │   apify_run_sync, linkedin_unipile_post
│  Unfinished rows first, then 14 days of posts   │   linkedin_unipile_profile, once per new person
│  from the actor, engagers, one profile each.    │   a company page's posts come from the actor
└────────────────────────┬────────────────────────┘
                         ├───────────────▶  ▪ seen before       verdict reached in an earlier run
                         ├───────────────▶  ▪ competitor staff  employed by a watched company
                         ▼
╔═════════════════════════════════════════════════╗
║  2 · Drop who the CRM already handles           ║   hubspot_object, linkedin_unipile_profile
║  Title against your personas, the employer's    ║   a company is never cleared on its name
║  domain from its page, then contact and deals.  ║   past the page cap, kept for next run
╚════════════════════════╤════════════════════════╝
                         ├───────────────▶  ▪ wrong function    no persona and no stated reason
                         ├───────────────▶  ▪ in the CRM        open deal or existing customer
                         ▼
┌─────────────────────────────────────────────────┐
│  3 · Score against your ICP                     │   no paid provider and no new call
│  Company fit from what step 2 read, person      │   scored apart, so a mismatch stays visible
│  fit from the title, then combined.             │
└────────────────────────┬────────────────────────┘
                         ├───────────────▶  ▪ below the bar     under your ICP threshold
                         ▼  above the bar only
┌─────────────────────────────────────────────────┐
│  4 · Find a work email, then check it again     │   fullenrich_enrich_linkedin, fullenrich_result
│  One enrichment job for the survivors, then     │   hubspot_object on the address and its domain
│  the CRM and Lemlist on the address found.      │   lemlist_lead by email, before any draft
└────────────────────────┬────────────────────────┘
                         ├───────────────▶  ▪ unreachable       no work email, kept by name
                         ├───────────────▶  ▪ in the CRM        found only by email or its domain
                         ├───────────────▶  ▪ in a sequence     already a lead in a campaign
                         ▼
┌─────────────────────────────────────────────────┐
│  5 · Stage a paused campaign                    │   lemlist_campaign, lemlist_team, lemlist_sequence
│  Sender, steps and a statute check, then one    │   lemlist_create_lead; approvals row opened first
│  opener per person, never the competitor.       │   a blocking statute stops before any lead
└────────────────────────┬────────────────────────┘
                         ▼
╔═════════════════════════════════════════════════╗
║  6 · A person approves, name by name            ║   slack_open_dm, slack_post_message
║  Drafts go to the approver by DM; a separate    ║   slack_read_thread, its own run on working days
║  check counts only their replies.               ║   every ts the run posted is excluded
╚════════════════════════╤════════════════════════╝
                         ├───────────────▶  ▪ not approved      rejected, never named or expired
                         ▼  approved names only
┌─────────────────────────────────────────────────┐
│  7 · Release the approved and log them          │   lemlist_lead, lemlist_campaign_start
│  Delete unapproved leads, resume the campaign,  │   lemlist_launch_lead, lemlist_campaign
│  launch and log each lead once, close the row.  │   hubspot_object, each result kept as it returns
└─────────────────────────────────────────────────┘

▪ terminal — the row stops there and nothing further is spent on it

Rule zero: engagement is a weak signal

A like is not intent, and treating it as intent is how this turns into spam. Everything after collection is filtering, and every filter that costs nothing runs before the one that costs money. Enrichment is the only per-person paid lookup and it runs last: enriching the whole engagement list buys you the discovery that most of it was students, employees and people in the wrong country, while scoring first means paying only for the fraction worth writing to. The Apify actor that lists the posts bills per item too, which is why it never launches without a ceiling.

0. Check the session and the watch list

  • linkedin_unipile_account with op="status" — confirm the LinkedIn session behind the collection before reading anything, and read alive, not only connected: an account stays linked after its session has died. Never call it without an op, since the default lists premium contracts and answers 403 on an account without a premium seat, which reads as "not connected" when it is. An expired session returns results that look thin rather than broken, which is the failure to watch for. A dead session makes the collection a no-op, and saying so is the correct outcome; a batch an earlier run left at staging (step 1's first read) is still finished, since that needs no LinkedIn call.
  • data_rows — read the watch list from a table rather than from memory: one row per watched competitor, with its LinkedIn company-page slug confirmed once by a person, and your positioning against it. Never resolve a page from a name search at run time. Company names collide, and every person harvested from the wrong page is qualified by the wrong competitor, which no later step can detect.
  • The same table holds the run's configuration: the pinned Apify actor, the engagement route the first run settled (step 1), [your weights] and [your threshold], and [the approver's Slack user id]. slack_open_dm takes the U… id, not an email or a display name, so resolve it once with slack_find_user_by_email and store it instead of looking it up each run.
  • apify_actor on the pinned actor's username/actor-name — free, since it reads the actor's card and runs nothing. It fails on a revoked key, or on an actor that was renamed or taken down, and step 1 cannot list a single post without that actor.
  • hubspot_property with op="list" and object_type="contacts" — a free probe of the CRM key before anything is collected. Step 2 is what stops the run from writing to your own customers, and a run that finds a dead key there has already spent its collection. The same read gives the internal name of your LinkedIn URL property, which is not the label shown in the interface.
  • On the first run, run step 2's contact search once against a contact whose LinkedIn URL you know is stored, and confirm it comes back. HubSpot tokenizes a URL its own way, and a portal where the slug search misses would otherwise look like a week of people nobody knows. If it does not come back, turn the LinkedIn match off in the configuration and let step 4's address search clear contacts on its own.

1. Collect the engagement

  • Every person row carries a status, and the run starts from it. A row is written at collected, moves to scored and enriched as it passes steps 3 and 4, and to staged, with its lead id and campaign id, once its lead exists. Every exit in steps 1 to 5 writes dropped with the exit's name as the reason, except unreachable, which is its own status, and seen before, which leaves the earlier verdict as it stands; steps 6 and 7 close a staged row as launched, launch failed, not approved or expired. collected, scored and enriched mean in progress; every other status is a verdict.
  • data_claim_next on the approvals table, worker set to the sweep's name and filter={"status": "staging"}, until no row comes back — the first read of unfinished work is a batch an earlier run staged and never put in front of the approver. Each claim is held to the end of the run and freed then with data_release, since a row freed mid-run goes back to the head of the queue and is served again in the same pass. What is done with each row is in step 6. It runs before the person rows are read because a lead created just before a run stopped can sit behind a person row still at enriched: the read below would send that person through step 4 again, and the lead the run itself created would end them as in a sequence.
  • data_rows with filter={"status": {"in": ["collected", "scored", "enriched"]}}, paged with next_cursor to the end — finish what earlier runs left before collecting anything new. A company-page cap, a throttled profile, a failed CRM search or a run that stopped halfway leaves rows with no verdict, and no other step reads them again: without this read they drop out of the process without a trace. Each resumed row picks up where its status says it stopped, so a person already scored is not scored again and an email already paid for is not bought twice. Resuming them first also completes their identifiers before this run's new engagement is matched against them.
  • apify_run_sync with the pinned actor — the actor lists each watched company page's posts, because the LinkedIn tools cannot. linkedin_unipile_profile with op="posts" cannot read a company page. It resolves its identifier as a member, turning a slug into a member URN through a profile read that has no company flag, so a company page's slug either errors or, when a person owns the same vanity slug, returns that person's posts under the page's name. Choose a company-posts actor once with apify_store_search, read its card with apify_actor, and pin its username/actor-name in the configuration table instead of searching the Store each run: an actor swapped between runs changes the shape of its output without announcing it. Pass the watched pages' URLs in one run where the actor accepts a list, set its own date or post-count limit to cover fourteen days, and always pass max_items and max_total_charge_usd, since an actor bills as it goes and a ceiling set afterwards protects nothing. Past 300 seconds the call answers 408: launch with apify_run, poll apify_run_status until SUCCEEDED, then page apify_dataset_items with fields cut to what the row needs.
  • From each post keep its URL, its date, its author and its activity id in the urn:li:activity:<number> form, taken from the actor's id field or from the number in the post URL; map the actor's own field names onto the row explicitly. Keep the posts dated within the last fourteen days. Two weeks is the window worth using: older engagement has gone cold, and a month is mostly noise from posts that traveled outside the buyer set.
  • Every post's author must be the watched company, checked against the page on its watch-list row, and a post by anyone else is dropped before its engagers are read: a company-posts actor can return reshares of other authors' posts. On the first run, check it page by page before any engager is collected. A page none of whose posts are by the watched company was resolved wrong: it is skipped, flagged in the report, and left for a person to fix its row, since every person harvested from it would be qualified against the wrong competitor.
  • linkedin_unipile_post with op="engagement" and post_id set to that urn:li:activity: id, once with kind="reactions" and once with kind="comments". They are two separate reads, both cursor-paged: page both to the end. A run that reads only the first page of reactions on a popular post silently drops most of the audience it came for. A reaction gives a name and a profile URL, often no headline and never a company; a comment gives its text as well.
  • Confirm on the first run that this read accepts the id the actor returned: run it on a post you can see has reactions, and check that they come back. If it errors, or returns nothing for that post, take the engagers from Apify instead, with the post URLs as input: the same actor where it can return reactions and comments, otherwise a second pinned engagement actor, under the same max_items and max_total_charge_usd. Record the settled route in the configuration table so later runs don't retry a read that returns nothing. One engagement route per run, never both: reading the same posts twice pays twice for the same people.
  • A person has two identifiers, and a row stores both. The profile URL the LinkedIn route returns often ends in the member URN (a segment starting ACoA), where an Apify actor gives the public slug, so one person arrives under two spellings depending on which route read the engagement. Each row carries linkedin_slug (lower-cased, no query string, no trailing slash) and member_urn (kept as returned, since a URN is case-sensitive), and neither the display name nor the raw URL is ever the key: the same person shows up with a middle initial on one post and an appended job title on another. Before writing, look every identifier on a page of engagement up in one data_rows read, with a single filters clause over both columns ({"fields": ["linkedin_slug", "member_urn"], "op": "in", "value": [...]}), which matches whichever column holds it. A new person is written with data_write at status="collected": the watched page, post URL and date, engagement type, the comment text, first_seen and last_seen.
  • Keep the comment text. A reaction says someone was interested; a comment says what in, in their own words, and that sentence is the best opener material this process produces.
  • Two free exits run first, both kept on the row with their reason rather than deleted:
    • Seen before — an identifier matches a row that already holds a verdict: last_seen is updated and the new post appended, then the person stops. No profile fetch, no enrichment, no second report: a person is reported once, ever. A person whose row is still in progress is not this exit: the new post is appended and the row stays in this run's queue, which already holds it from the first read.
    • Competitor staff — an engagement whose headline names the watched company or another company on the list ends here without a fetch. Everyone else is decided on the employer the profile fetch below returns. The share of each page's audience that is its own staff tells you whether that page is worth watching at all.
  • One profile fetch per new person, after those exits. linkedin_unipile_profile with op="person" on whichever identifier arrived returns public_identifier and provider_id together, with the current title and the current employer's company page: write all four on the row. Step 2 needs the title and the employer, step 4 needs the slug, and a row holding both identifiers is found again whichever route brings the person back. Identify a member by slug or URN, never by the numeric id a search returns, which the profile API rejects.
  • Profile fetches are the scarce LinkedIn call. op="person" can return throttled_sections with an empty experience block despite a non-zero experience_total_count: that is an upstream rate limit, not a person with no job history. Retry those in a catch-up pass minutes later; a profile still throttled after it stays at collected for the next run, since a throttled section and an absent employer look alike and neither is a verdict. Keep parallelism at eight or below, and check that the returned public_identifier or provider_id matches the one you asked for before writing anything from it.

2. Drop who the CRM already handles

Every read here is free of credits, and a row is cleared only on an identifier that cannot be spelled two ways. A company name is not one of them: a record called "Acme Group" with an open deal and a LinkedIn page called "Acme" are one company to a buyer and two strings to a filter.

  • Wrong function first, since it needs no call: read each title against your written buyer personas, not intuition. Recruiters, students, sales reps at other vendors and consultants selling the same category end here. A title that maps to no persona is not automatically dropped, but it needs a stated reason, written on the row, to survive; with no persona and no reason, the row ends here.
  • hubspot_object with op="search" on contacts — your LinkedIn URL property (the internal name step 0 read) with CONTAINS_TOKEN and the slug between wildcards, *<slug>*, so a URL stored with or without the scheme, www. or a trailing slash matches alike. A hit is read with op="get" and associations=["companies"], which returns its company ids inline, and that company is the one tested below. A miss proves nothing: a contact created from an email or an event list has no LinkedIn URL at all, which is why step 4 searches again on the address.
  • linkedin_unipile_profile with op="company" on the employer's page from step 1's fetch, once per company per run however many of its people engaged. Take its website and normalize it to a domain: lower-case, no scheme, no www., no path. A page is cached for six hours per account but capped at roughly 100 pages per 12 hours per account: past the cap, the rows still waiting stay at collected for the next run's first read, rather than being checked on a name. Check the returned public_identifier against the page you asked for: a page that is not found is retried against a company name search, and can come back as another company.
  • hubspot_object with op="search" on companies and query set to that domain. HubSpot's full-text search runs across domain and website (and name and phone), so a record that holds the site only in website is found too; keep only a hit whose normalized domain or website equals yours, since full-text also returns near matches. Ask for lifecyclestage, numberofemployees, industry and country in the same call: step 3 reads them from there. Search each domain once per run.
  • Where no domain resolves (no current company page, or a page with no website), the same search runs with query set to the employer's name, and its hits are candidates, never matches. A candidate with an open deal or customer status ends the row as in the CRM, with the candidate's id and name written on the row so a person can reverse it: a missed opener costs less than a cold email to an open opportunity. A candidate with neither is recorded on the row for step 4 to settle, and nothing is written to it.
  • hubspot_object with op="search" on deals, filtered on associations.company EQ the company id, with properties=["hs_is_closed","hs_is_closed_won"] — one call returns every deal of that company with both flags, instead of an association read followed by a read per deal. An open deal or an existing customer ends the row: a customer is lifecyclestage = customer, any closed-won deal, or [your customer property] if you keep one. A cold opener to an open opportunity tells the buyer nobody reads the CRM. A record with neither is kept with its ids, so step 3 reads it and step 7 updates it rather than creating a second.
  • HubSpot limits its search endpoints to a few requests per second per account, well below its general limit. Pace the batch, and remember the connector retries a refused call for a bounded time and then fails it. A search that fails is not a miss: the row keeps its status for the next run and never reads as clear.

3. Score against your ICP

No paid provider is called and no new read is made. A person profile does not carry the employer's headcount or sector, and step 2 has already read the company:

  • The CRM record's numberofemployees, industry and country when step 2 matched one; otherwise the headcount, industry and location the company page states.
  • Score the company on the axes you actually sell against, [headcount band], [priority sectors] and [geography], and the person on how closely the title sits to the buying role. Combine them with [your weights]. Scoring the two apart keeps a perfect-fit company with the wrong contact visible as exactly that, instead of averaged into the middle.
  • Treat geography as a preference unless you have a genuine hard exclusion, and keep hard disqualifiers to a short written list. An invented disqualifier silently discards good leads every week and nobody sees it happen.
  • Below [your threshold] the row is dropped, counted and given its reason; above it, the row moves to scored with the score and its reasoning. The bar is real: the next step costs money, and a run that scores everyone and then enriches everyone has implemented a list purchase, not this process.

4. Find a work email, then check it again

  • fullenrich_enrich_linkedinone job for the whole surviving batch (up to 100 contacts per job) rather than a call per person. Each contact carries first_name and last_name, linkedin_slug set to the public_identifier step 1's fetch stored (the slug, never the URN or the URL) and the company domain where step 2 resolved one: the names help the match, and a contact with neither a slug nor a domain gets the whole job rejected. Ask for enrich_fields=["contact.work_emails"] only: phones cost far more than a work email, and this process writes rather than calls.
  • fullenrich_result — first poll after about 30 seconds, then every 20 to 30 seconds until the job reports finished. A work email found is written to the row, which moves to enriched.
  • No work email makes the row unreachable, and it stays on the list by name: a named person at a target account is still useful to whoever owns that account. Never fall back to a personal address. This is a work problem, raised at work.
  • Search the CRM again on what enrichment revealed, before anything is drafted. hubspot_object with op="search" on contacts and query set to the address, which HubSpot runs across email and hs_additional_emails, keeping only an exact match. Then, when the address's domain differs from step 2's, or step 2 resolved none, the company search from step 2 on that domain: a group domain, a rebrand or a page with no website all hide a record step 2 could not reach. A contact or company found here goes through step 2's deal and customer test, and failing it ends the row as in the CRM; passing it keeps the ids for step 7. A name candidate from step 2 is confirmed only when this domain matches its domain or website, and otherwise stays a candidate. The credit is already spent on a row dropped here, which is the price of a record step 2 could not see, and the reason step 2 resolves the domain before anything is bought.
  • lemlist_lead with op="get" and email — once per work email that is still in play. It looks the address up across every campaign, which lemlist_get_leads cannot: that one lists a single campaign's leads. A lead under that address ends the row as in a sequence, because a second opener to someone already in another sequence is the failure this check exists for.
  • Only a clean not-found is a pass, in the CRM and in Lemlist alike: a call that fails leaves the row at enriched for the next run, which repeats these free checks and never buys the email again.

5. Stage a paused campaign

  • Write one opener per person from the strongest evidence available, in this order: what they said (their comment, paraphrased back), then what they engaged with (the subject of the post, named specifically, not the act of reacting). A row with neither should not have survived this far.
  • Never name the competitor. The person engaged with a post about a problem, so the message is about the problem. Naming the vendor tells them they were scraped and puts you in a comparison you did not choose. The positioning in the watch list is for when the prospect raises the competitor themselves.
  • Keep the opener plain text. Email steps are usually HTML and a variable is dropped in verbatim, so an ampersand or an angle bracket breaks the markup around it.
  • lemlist_campaignop="create" lands the campaign running and returns its sequenceId, so op="pause" is the very next call, before a sender, a step or a lead is added.
  • data_writeopen the approvals row as soon as the campaign is paused, before its sender, its steps or any lead: one row keyed on the campaign id, with its sequenceId and status="staging". Then claim that row with data_claim_next and filter={"campaign_id": "<the campaign id>"}, held to the end of the run like the claims of step 1's first read, so an overlapping sweep cannot post a batch this run is still staging. From this call on, the paused campaign has a row pointing back to it: a run that stops mid-staging, or cannot reach the approver, leaves a batch the next sweep finishes rather than leads held forever in a campaign nothing reads, each of them barred from every later campaign by deduplicate=true.
  • lemlist_team with op="user_channels" — the key's user (usr_…) and the mailboxes it can send from; pass that id to lemlist_campaign op="update" as sender_user_ids. A new campaign has no sender, and without one every launch in step 7 fails, after a person has already spent their approval on it.
  • lemlist_sequenceop="add_step" on that sequenceId, once per step of [your sequence steps] with its delay, the first email's message opening on {{opener}}. A new campaign has no steps, so without this a lead released in step 7 has nothing to receive. Then read the sequence back with op="get" and list every {{variable}} its steps reference: a lead is created only when it carries every one of them.
  • Read lemlist_campaign op="statutes" before the first lead. It runs the same validation as the Lemlist interface. Level 3 (no sender, broken DNS) blocks a launch, so the run stops there: no lead is created, no approval is requested, the empty campaign stays paused, its approvals row closes as empty with the blocker written on it, the rows stay at enriched for the next run, and the report names the blocker. Level 2 (a daily limit, a missing schedule) does not block, and is quoted in the approval request so the approver knows what they are approving into.
  • lemlist_create_lead — write each opener on its person row first, then pass opener and every other referenced value in custom_variables at creation, with deduplicate=true. Patching variables afterwards has applied only the first of several sent in one call. A create that deduplication skipped yields no lead id: the address reached another campaign after step 4's check, so the row ends as in a sequence just the same, and its draft is never numbered or posted in step 6. Straight after each create that returns a lead id, write staged, the lead id and the campaign id on the person row, and append the lead id with its draft to the approvals row. A stop between a create and those two writes cannot be ruled out, which is why a batch left at staging is rebuilt from the campaign's own listing (step 6), with the opener already on the person row. The lead lands held for review: creating it sends nothing, and it waits until step 7 launches it by id. That holds only while lemlist_campaign_auto_review stays off, and this process never turns it on: with it, every lead added here would send on creation and step 6 would approve mail already gone.

6. A person approves, name by name

  • slack_open_dm with the approver's stored user id, then slack_post_message — the request goes to the approver's direct messages, not a shared channel: a list of named prospects is not something a whole team needs to read. The top message carries the counts (collected, dropped at each filter, scored above the bar, enriched, unreachable, in the CRM after enrichment, in a sequence, left in progress for the next run), any level 2 statute, and the ask: one reply naming, by number, every lead to send. Each staged lead's numbered draft goes as a reply in its thread, and only a lead with an id gets a number. Text over roughly 4,000 characters is split into threaded parts rather than truncated, so read split_into and ts_all in the response instead of re-posting.
  • data_write — on the approvals row step 5 opened at staging: first each draft number mapped to its lead id, before anything is posted, then the DM channel id slack_open_dm returned (D…, which the thread read requires), the top message's ts, and own_ts: every ts the run posted — the top message, each draft reply, and every part in ts_all when a message was split — each written as its post returns. The row moves to pending only once every draft is posted, and a run in which deduplication skipped every create posts nothing and closes the row as empty. The check reads no staging row, so it never counts replies in a thread whose own posts are not all recorded. If the direct message cannot be opened, or a post fails, the row stays at staging with the error written on it and the campaign stays paused; don't reroute the list to a channel. The next sweep retries it and reports it, keeping the numbers already written and posting only what the row does not yet record, so the approver never gets the same draft twice.
  • A batch left at staging is finished by the next sweep, on each row step 1's first read claimed. lemlist_lead with op="list" on its campaign is the batch, not the approvals row: a create that returned just before a run stopped leaves a lead no row records. Each listed lead is matched to the person row holding its lead id and that campaign id. A listed lead no person row holds is read with op="get" and its lead_id for its email, and the enriched row holding that address is written staged with the lead id and the campaign id; a person row whose lead the listing no longer holds was removed by hand and moves to not approved. The draft list is rebuilt from those rows' openers, lemlist_campaign op="statutes" is read again so the request quotes what holds now, and the request is posted as above, after which the row moves to pending. A level 3 statute read here keeps the row at staging with the blocker on it, like a direct message that cannot be opened. A campaign that lists no lead holds nothing to approve, whether a run stopped before its first create or deduplication skipped every one: the row closes as empty, and any person row still at enriched is staged by this run into a campaign of its own.
  • The approvals row has a lifecycle, and only its three open statuses are ever read again. staging means the batch is not yet in front of the approver, and only the weekly sweep reads it: step 5 writes it before the first lead, and the row moves on once every draft is posted. pending waits for a reply. releasing means step 7 has begun, and the row records each call's result as it returns: campaign_started, then per lead its launch outcome, its CRM ids and whether its note is written. The check reads only pending and releasing. released, rejected, expired, empty and needs_attention are closed, and a closed row is never processed again. A row left pending after its release would be found again by the next check, with the same approving reply in its thread, and released a second time.
  • The answer is read by a separate check with its own recurring run, every [interval] on working days, not by the next weekly sweep: a fourteen-day signal should not wait a week for its reply. It takes its rows with data_claim_next, worker set to the check's name and filters=[{"field": "status", "op": "in", "value": ["pending", "releasing"]}], until no row comes back. A claim keeps an overlapping check off a row that is being released, and a check that dies halfway leaves its claim to lapse with the lease, after which the next check resumes the row: set lease_s longer than a check takes. Hold every claim to the end of the run, then free each with data_release: a pending row freed mid-run goes back to the head of the queue and is served again in the same pass. Write last_checked on each pending row read, so a row that waits days for its reply does not look like one claimed over and over with nothing written.
  • On each pending row, slack_read_thread with the stored channel id and top message ts, following next_cursor to the end.
  • Count only the approver's replies: a reply counts when its user equals the stored approver id and its ts is not in own_ts. The run posts as the connected Slack account, so its own draft replies carry no bot_id and name every lead by number and name: a bot_id test would read them as a person approving the whole batch. When the connected account is the approver's own, the user id cannot tell the two apart either, and own_ts is the only guard, so it is written before the check can ever run. Anything the check posts in the thread is appended to own_ts the moment it is posted.
  • Only an explicit per-name approval counts. A lead is approved when a counted reply names its number or name. A lead the reply rejects, or never mentions, is not approved. A reply that names nobody ("looks good", "ok") is ambiguous: the check quotes it back in the thread and adds its ts to the row's handled_ts, so it is quoted once rather than at every interval, and the row stays pending. A reply that rejects every lead closes the row as rejected, once its leads are deleted the way step 7 deletes unapproved ones and its people's rows are at not approved. With no counted reply the row stays pending and nothing is released; after [your expiry window] its leads are deleted the same way, the row closes as expired, its people's rows move to expired, and the campaign stays paused. Ask name by name rather than for the batch: a gate that approves everything is worth nothing.

7. Release the approved and log them

This runs inside the check, for a pending row whose counted replies approve at least one name, and for any row it claims already at releasing. Each call's result is written to the approvals row the moment it returns, and each call is safe to meet twice, so a check that stops halfway is resumed by the next from what the row records, not started over.

  • Move the row to releasing before any release call, in one write carrying the approver's user id, the approving replies' ts and the approved lead ids. From then on the approved set is read from the row, never re-derived from the thread: the record shows who launched what, and a reply posted later cannot change it.
  • lemlist_lead with op="list" on the campaign, then op="delete" on every listed lead outside the approved set, rejected and never mentioned alike, before anything is released: a lead left in the campaign can still be launched by hand from the Lemlist interface, or sent by an auto-review someone arms later. Their people's rows move to not approved. Deleting from the listing rather than from what the row once staged means a second pass deletes nothing. Then op="list" again and confirm that what remains is exactly the approved set. On any mismatch, release nothing: the row closes as needs_attention with the difference written on it, and the thread gets one message saying so, posted once because a closed row is never read again.
  • lemlist_campaign_start — resumes the campaign paused in step 5, skipped when the row already records campaign_started. It sends nothing on its own: it runs the sequence only for leads that have been launched, and every lead here was created held for review. A check that stops at this call reports "started" while nobody receives anything. On a campaign that is already running, Lemlist answers 400 "You can't start campaigns that are already running", not the no-op the tool's description promises, and that is what a pass meets when an earlier one resumed the campaign and stopped before writing it down: read that answer as started and write campaign_started. Any other error leaves the row at releasing for the next check.
  • lemlist_launch_lead — once per approved lead id that has no launch outcome on the row yet, read from the row rather than from the campaign listing. This is the send. It is hidden by default, like the start call: enable both deliberately for this check, never as a side effect of another step. Write each outcome to the row as its call returns. CAMPAIGN_LEAD_REVIEW_LEAD_ALREADY_LAUNCHED is recorded as launched, not as a failure: it is what a pass meets for a lead an earlier pass launched and stopped before recording. Any other error (no sender available, a step error) is recorded as a failed launch against the name, and the others still go. Each approved person's row moves to launched or launch failed.
  • hubspot_object — for each approved lead. One that step 2 or step 4 matched is updated by the id kept there, and its company link is left as it is. For one neither matched, and for which the row holds no CRM id yet, run step 4's address search once more just before op="create": the approval can land days after the sweep, and a rep may have added the person since. Create it with its associations to the company matched on a domain in step 2 or step 4, which the tool accepts only on create: a contact created without them lands unlinked. Where no company matched on a domain but a name candidate exists, create the contact unlinked and name it in the check's report for a person to associate, rather than link it to a company nothing confirmed. Where no company matched at all, create the company first on its normalized domain and associate the contact to the new id. Write each id a create returns to the row straight away, the company's before the contact is created, so a resumed pass reuses them instead of creating a second record.
  • Then op="add_note" with the evidence, only for a lead the row does not yet mark as noted, and mark it the moment the note returns: a note is appended, never merged, so a note written twice sits twice on the record. The evidence is the watched page, post URL and date, engagement type, the comment text, and the score with its reasoning. The note renders only h4, p, b, ul, li, br and i, and any other tag is dropped without an error. The competitor is named in the note, which the prospect never sees.
  • lemlist_campaign with op="reports" and campaign_ids=[<the campaign id>] — confirm that inSequenceLeadCount equals the number of leads the row records as launched. A match closes the row as released. A mismatch closes it as needs_attention, written on the row and posted once in the thread, never fixed by launching again blind: a launched lead cannot be recalled.
  • lemlist_campaign_auto_review is never turned on, here or anywhere in this process.

Output

The weekly sweep reports per watched page: engagements collected, how many were dropped at each filter and why (seen before, competitor staff, wrong function, in the CRM before enrichment, below the bar, in the CRM after enrichment, in a sequence), how many were enriched and how many are unreachable, how many rows from earlier runs were finished, how many are still in progress for the next run (a data_rows count with count_only=true on the three in-progress statuses) and which cap or failed call stopped them, the engagement route the run used, posts dropped for another author and any page skipped as resolved wrong, any statute that blocked staging, and the paused campaign it staged with the status its approvals row ended at. The same goes for each batch an earlier run left at staging, with any lead found in its campaign that no row had recorded: pending once its request is posted; empty; or still staging, with the error that kept it there (a direct message that could not be opened, a failed post, a blocking statute), reported at every sweep until a person clears it. The check reports, per campaign, who approved which names, which leads were removed, which approved leads were launched and which failed to launch, which new contacts were left unlinked for a person to associate, whether inSequenceLeadCount matched the launched count, and the status each approvals row ended at: released; needs_attention with the difference that closed it; rejected or expired; still pending; or still releasing, with the call that stopped it.

forkez cette doctrine dans votre espace oto.

ajouter à oto