B2B Buying Signals: The LinkedIn Ones and How to Track Them
Most buying-signal advice starts with an intent-data contract. A good share of the signals worth acting on are already visible from your own LinkedIn account – if you know where to look and collect them consistently.
The short version. A buying signal is behaviour that suggests an account may be moving toward a purchase – visiting your pricing page, hiring for the role your product serves, engaging with your content, or asking a direct question about pricing or timing. Some come from vendor intent data; others you can observe on LinkedIn: who viewed your profile, who engaged with your posts or a competitor's, who is posting about the problem you solve, who is hiring for the role you sell to, and who just accepted your invitation or replied. You can collect those from your own account on a schedule, without an intent-data contract. Treat every one as a reason to look closer, not as proof someone is ready to buy.
What counts as a B2B buying signal
A buying signal is any observable behaviour that makes a purchase more likely than it was before. It falls into three kinds:
- Direct engagement with you – visits to your profile, reactions and comments on your posts, replies, accepted invitations, and the strongest of all: a direct request for a demo, pricing or timing.
- Public business and context observations – engagement on a competitor's post, public posts about the problem you solve, hiring for a role, a job change.
- Vendor intent datasets – topic research and website visits that intent-data vendors aggregate and sell.
LinkedIn's own marketing blog draws the line between first-party intent data you gather yourself and data bought from others. This guide covers the first two kinds together – the LinkedIn signals your account can observe.
No single signal proves intent. A profile view can be curiosity, a like can be politeness, and a hiring post can be a backfill. Signals earn attention in combination and in context – which is why the rest of this guide pairs every signal with a strength and a route.
The LinkedIn buying signals you can see yourself
The strength grades below are editorial heuristics, not validated purchase probabilities – a starting point to tune against what actually turns into conversations for you.
| Signal | What it may suggest | Strength (heuristic) | How to pull it with Linked API |
|---|---|---|---|
| Someone viewed your profile | They looked you up after a post, comment or invitation | Weak alone; medium with ICP fit plus recent engagement | retrieveProfileViewers – identified viewers with name and profile. Semi-private viewers come back only as LinkedIn's description plus a search link, never a name; full-private viewers are not returned |
| Someone reacted to your post | The topic landed | Weak; medium with ICP fit when the post is about the problem | fetchPost with reactions (st.retrievePostReactions in the workflow API) – the response has no reaction time, so "first seen" is when your run noticed it |
| Someone commented on your post | Active interest in the topic | Medium; strong if they ask a problem, pricing or timing question | fetchPost with comments (st.retrievePostComments), with LinkedIn's relative time such as 3d |
| Someone engaged with a competitor's post | Interest in the category | Weak; medium with ICP fit and a category-relevant post | The same reactions and comments, on the competitor's post |
| Someone posted about the problem you solve | They are thinking about it in public | Medium when the author fits your ICP and the post describes the problem | searchPosts on a keyword – results can be loosely related, and each call is bounded (up to 100 posts) |
| A target account is hiring for the role you sell to | Budget and a team forming around the problem | Medium; weak outside your target accounts | searchJobs filtered by company |
| Someone accepted your invitation | Regular messaging is now open | Not a buying signal on its own | Network monitoring – connectionAccepted events, polled or by webhook |
| Someone replied | A conversation started | Strong if it is a positive question about the problem, pricing or timing; a negative reply means suppress | Inbox monitoring – inbox.messageReceived events |
| A champion changed jobs | Someone who knows your product lands somewhere new | Medium to strong when the new company fits your ICP | No native event: re-fetch saved profiles on a schedule and compare company and title |
A few boundaries worth knowing before you build on these:
- Profile views depend on what LinkedIn shows your account – Premium shows more of the list – and a semi-private viewer is an account-level observation at most: it names a company only when LinkedIn discloses one, and it never identifies a person or proves a repeat visit (viewing modes). The full picture is in who viewed my profile.
- Post engagement works on any public post URL, yours or a competitor's – see turning post engagers into warm leads.
- Keyword posts come from LinkedIn's content search – see searching posts by keyword.
- Hiring comes from jobs search – see monitoring new job postings.
How to identify buying signals that matter
Collecting everything produces noise. A short routine keeps it useful:
- Define the accounts and roles you sell to. Without an ICP check, every signal looks equally interesting.
- Pick the three or four signals that fit your motion, not all nine. A founder who posts weekly gets most from post engagement; a team selling to a named account list gets most from hiring and job changes.
- Watch them on a schedule – daily is enough for most of them.
- Score combinations above single events. A viewer from a target account who also engaged with a post beats either alone.
- Review weekly what turned into conversations, and adjust which signals you keep.
Then route each observation with one small rule set:
| Condition | Route |
|---|---|
| ICP and role fit and relevant content and recent | Research and review queue – a person checks before any outreach |
| A direct positive question about the problem, pricing or timing | Follow-up task – reply, offer a call |
| Negative reply, no fit, or unrelated content | Suppress |
| A single weak signal with no fit check yet | Log only |
How to respond to a buying signal
- Lead with relevance, not surveillance. Refer to the shared context – the post, the topic, the role – and never open with "I saw you viewed my profile".
- Answer direct questions directly. A comment asking about pricing or timing deserves a reply in the thread or a short message, not a sequence.
- Pick the right channel. Check the connection degree to choose between an invitation and a message.
- Keep the pace human. Stay inside LinkedIn's practical limits; more on first-touch tactics in cold outreach strategies.
Collecting LinkedIn signals automatically
Below is one daily job that pulls three signals – new profile viewers, engagement on your last three posts, and recent posts on a keyword – routes each one with the table above, and appends it to a review file. Each line of the file is one observation:
- who – name, profile URL and URN when known, or LinkedIn's description when that is all it shows;
- signal type and source – the post, the search term, the profile-viewers list;
- observed at – when this run first saw it, not when it happened;
- evidence – the comment text, the post excerpt, the viewer's relative time, and a comment's age as LinkedIn shows it;
- rule and action – which routing condition matched and what to do next.
import LinkedApi from '@linkedapi/node';
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
const linkedapi = new LinkedApi({
linkedApiToken: 'your-linked-api-token',
identificationToken: 'your-identification-token',
});
const MY_PROFILE_URL = 'https://www.linkedin.com/in/your-profile';
const KEYWORD = 'stale pipeline';
const STATE_FILE = 'signals-state.json';
const OUTPUT_FILE = 'signals.jsonl';
const OVERLAP_MS = 2 * 24 * 60 * 60 * 1000;
const ICP_ROLE = /revenue operations|revops|sales operations/i;
const PROBLEM_TERMS = /pipeline|forecast|crm/i;
const BUYING_TERMS = /pric|cost|demo|trial|timeline|budget/i;
const NEGATIVE_TERMS = /not interested|no thanks|unsubscribe|stop messaging|remove me/i;
const RECENT_AGE = /^\d+\s*(s|m|h|d|w)$/i;
interface TSignal {
key?: string;
name?: string;
profileUrl?: string;
urn?: string;
headline?: string;
type: 'profile-view' | 'reaction' | 'comment' | 'keyword-post';
source: string;
evidence: string;
age?: string;
isAboutProblem: boolean;
}
async function collectProfileViews(since: string) {
const signals: Array<TSignal> = [];
const workflow = await linkedapi.retrieveProfileViewers.execute({ limit: 100, since });
const { data, errors } = await linkedapi.retrieveProfileViewers.result(workflow.workflowId);
if (errors.length > 0) throw new Error(`Profile viewers: ${errors.map((error) => error.type).join(', ')}`);
for (const viewer of data ?? []) {
if (viewer.viewerType === 'anonymous') {
signals.push({ type: 'profile-view', source: viewer.searchUrl, evidence: viewer.description, isAboutProblem: false });
continue;
}
signals.push({
key: `view:${viewer.urn ?? viewer.publicUrl}`,
name: viewer.name,
profileUrl: viewer.publicUrl,
urn: viewer.urn ?? undefined,
headline: viewer.headline ?? undefined,
type: 'profile-view',
source: 'profile viewers',
evidence: `Viewed ${viewer.viewedAgo ?? 'recently'}`,
isAboutProblem: false,
});
}
return signals;
}
async function collectEngagement() {
const signals: Array<TSignal> = [];
const profileRun = await linkedapi.fetchPerson.execute({
personUrl: MY_PROFILE_URL,
retrievePosts: true,
postsRetrievalConfig: { limit: 3 },
});
const { data: me, errors: profileErrors } = await linkedapi.fetchPerson.result(profileRun.workflowId);
if (profileErrors.length > 0) console.warn('Own posts', profileErrors.map((error) => error.type));
for (const { url } of me?.posts ?? []) {
const postRun = await linkedapi.fetchPost.execute({
postUrl: url,
retrieveReactions: true,
reactionsRetrievalConfig: { limit: 100 },
retrieveComments: true,
commentsRetrievalConfig: { limit: 50, sort: 'mostRecent' },
});
const { data: post, errors } = await linkedapi.fetchPost.result(postRun.workflowId);
if (errors.length > 0) console.warn(url, errors.map((error) => error.type));
if (!post) continue;
const isAboutProblem = PROBLEM_TERMS.test(post.text ?? '');
for (const reaction of post.reactions ?? []) {
if (reaction.engagerType !== 'person') continue;
signals.push({
key: `reaction:${url}:${reaction.engagerUrn ?? reaction.engagerUrl}`,
name: reaction.engagerName,
profileUrl: reaction.engagerUrl,
urn: reaction.engagerUrn ?? undefined,
headline: reaction.engagerHeadline,
type: 'reaction',
source: url,
evidence: `Reacted (${reaction.type}); reaction time not available`,
isAboutProblem,
});
}
for (const comment of post.comments ?? []) {
if (comment.author.type !== 'person') continue;
const { name, profileUrl, headline } = comment.author;
signals.push({
key: `comment:${comment.commentUrl ?? `${url}:${profileUrl}:${comment.text}`}`,
name: name ?? undefined,
profileUrl: profileUrl ?? undefined,
headline: headline ?? undefined,
type: 'comment',
source: comment.commentUrl ?? url,
evidence: comment.text ?? '',
age: comment.time,
isAboutProblem: isAboutProblem || PROBLEM_TERMS.test(comment.text ?? ''),
});
}
}
return signals;
}
async function collectKeywordPosts() {
const signals: Array<TSignal> = [];
const searchRun = await linkedapi.searchPosts.execute({
term: KEYWORD,
filter: { sort: 'latest', datePosted: 'pastWeek' },
limit: 20,
});
const { data, errors } = await linkedapi.searchPosts.result(searchRun.workflowId);
if (errors.length > 0) console.warn('Keyword search', errors.map((error) => error.type));
for (const post of data ?? []) {
if (post.author?.type !== 'person' || !post.author.profileUrl) continue;
signals.push({
key: `post:${post.url}`,
name: post.author.name ?? undefined,
profileUrl: post.author.profileUrl,
headline: post.author.headline ?? undefined,
type: 'keyword-post',
source: post.url,
evidence: (post.text ?? '').slice(0, 280),
isAboutProblem: PROBLEM_TERMS.test(post.text ?? ''),
});
}
return signals;
}
function route(signal: TSignal, hasQualifyingEngagement: boolean) {
if (signal.type === 'comment') {
if (NEGATIVE_TERMS.test(signal.evidence)) return { rule: 'negative response', action: 'suppress' };
if (signal.age && !RECENT_AGE.test(signal.age)) return { rule: 'older than a month', action: 'log only' };
if (signal.evidence.includes('?') && BUYING_TERMS.test(signal.evidence)) {
return { rule: 'question about pricing or timing – confirm it is positive', action: 'follow-up task' };
}
}
if (!signal.headline) return { rule: 'no person to check fit', action: 'log only' };
if (!ICP_ROLE.test(signal.headline)) return { rule: 'outside the roles you sell to', action: 'suppress' };
if (signal.type === 'profile-view') {
return hasQualifyingEngagement
? { rule: 'role fit, plus qualifying engagement in this run', action: 'review queue' }
: { rule: 'single weak signal', action: 'log only' };
}
if (!signal.isAboutProblem) return { rule: 'unrelated content', action: 'suppress' };
return { rule: 'role fit and problem-related content', action: 'review queue' };
}
async function runDailyCollection() {
const state = existsSync(STATE_FILE)
? JSON.parse(readFileSync(STATE_FILE, 'utf8'))
: { lastRunAt: new Date().toISOString(), seen: [] };
const seen = new Set<string>(state.seen);
const runStartedAt = new Date().toISOString();
const since = new Date(Date.parse(state.lastRunAt) - OVERLAP_MS).toISOString();
const signals = [
...(await collectProfileViews(since)),
...(await collectEngagement()),
...(await collectKeywordPosts()),
].filter((signal) => !signal.key || !seen.has(signal.key));
const engagedPeople = new Set<string>();
for (const signal of signals) {
const personId = signal.urn ?? signal.profileUrl;
const { action } = route(signal, false);
if (signal.type !== 'profile-view' && personId && (action === 'review queue' || action === 'follow-up task')) {
engagedPeople.add(personId);
}
}
for (const signal of signals) {
const personId = signal.urn ?? signal.profileUrl;
const { rule, action } = route(signal, personId !== undefined && engagedPeople.has(personId));
appendFileSync(OUTPUT_FILE, `${JSON.stringify({ ...signal, observedAt: runStartedAt, rule, action })}\n`);
if (signal.key) seen.add(signal.key);
}
writeFileSync(STATE_FILE, JSON.stringify({ lastRunAt: runStartedAt, seen: [...seen] }));
}
runDailyCollection();What the code checks, and what it leaves to you. The routing applies the table above as far as the data allows:
- Role fit – a pattern on the person's headline.
- Relevance – the problem terms in the post, or in the comment itself.
- Recency – keyword posts come from the past week; a comment older than a month goes to the log.
- Negative responses are suppressed before anything else.
- A profile view reaches the review queue only when the same person also has a qualifying engagement in the same run.
What it cannot check is left to the person working the queue: when a reaction happened (LinkedIn gives no time), whether a question is actually positive, and whether a headline match really is someone you sell to. That is why the strongest route is a review queue, not an automatic message.
What the job deliberately does, and why:
- It checks
errorson every result. If the viewer pull fails, the run stops before it moveslastRunAt, so the next run covers the same window instead of skipping it. Errors from the profile, post and keyword fetches are printed even when part of the data came back – a failed reactions or comments pull would otherwise look like an empty source – and the run continues; whatever was missed is picked up next time, because seen keys are stored per observation. sinceis a coarse filter with an overlap. Viewer times are estimates from LinkedIn's relative ages, so each run reaches two days back and drops what it has already seen.- Identified viewers are deduplicated by URN, falling back to the profile URL. Semi-private rows carry no identity, so the overlap can log the same view twice – treat them as account-level observations, not counts.
- Reactions have no time. The first run logs every reaction already on your last three posts; after that, only new ones appear.
- Keyword search is loose. The problem-terms check suppresses posts that matched the keyword but are not about the problem.
- Combinations count only qualifying engagement, matched per person by URN where the source returns one and by profile URL otherwise – so a view and a comment from the same person may not always pair up.
Run it once a day from cron or any scheduler, then work the review queue and follow-up task lines.
The other signals plug into the same file:
- Accepted invitations and replies. Turn on network monitoring and inbox monitoring once per account, then poll for events or receive them by webhook –
network.connectionAcceptedandinbox.messageReceived. Deduplicate by the eventid, since a retried delivery reuses it. Monitoring captures only changes after you enable it. - Job changes. There is no native job-change event. Save the people who championed you, re-fetch their profiles with
fetchPersonon a schedule – weekly is plenty – and compare the current company and title with what you stored. - Hiring at target accounts. Run
searchJobswith your target companies in the filter and a role term, and log postings you have not seen before.
You can also skip the code. An AI agent can run the same routine on your account through the MCP server, the agent-friendly CLI or a ready-made skill – "check my new profile viewers and the engagement on my last three posts, and list the people who match these roles".
What this does not cover
- No third-party intent data – no "account X is researching your category" topic surges.
- No website-visitor identification.
- No personal emails or phone numbers.
If you need those, they come from intent-data and enrichment vendors; LinkedIn signals complement them rather than replace them. Everything here runs on your own account, in a dedicated cloud browser, at a human pace, within per-action limits you can configure.
Frequently Asked Questions (FAQ)
Behaviour that suggests an account may be moving toward a purchase – engaging with your content, hiring for the role your product serves, or asking a direct question about pricing or timing. Each one is a reason to look closer, not proof of intent.
Profile views, reactions and comments on your posts, engagement on a competitor's post, posts about the problem you solve, hiring for a relevant role, a champion changing jobs, accepted invitations, replies, and pricing-page visits or topic research from intent-data vendors.
A weak one on its own; it gets stronger combined with ICP fit and recent engagement. Semi-private viewers show only LinkedIn's description, so they can point to an account at most, never a person.
Yes, by re-fetching saved profiles on a schedule and comparing company and title. LinkedIn offers no job-change event to subscribe to.
No. The LinkedIn signals your account can observe work on their own; vendor intent data adds topic research and website visits that you cannot see on LinkedIn.
Wrapping up
The signals worth acting on are often already in your own account: who looked, who engaged, who is posting about the problem and who is hiring. Collect them on a schedule, route them with a few rules, and spend your time on the combinations.
Build it into your own tools with the REST API, the Node and Python SDKs or the CLI. Or have an AI agent run it out of the box through the MCP server, the agent-friendly CLI or a ready-made skill. Plans start at $49 a seat a month billed annually – see pricing.