Is marketbetter - AI-powered LinkedIn workflows safe?
marketbetter - AI-powered LinkedIn workflows is high risk. Starting the Sales Navigator list workflow, the extension uses your LinkedIn session to read lead/profile records, converts each into MarketBetter fields, and posts them to marketbetter.ai/api/private/plays/initiate-chrome-extension.…
Who publishes ittalk2sunder - no other listings under this identity
talk2sunder - no other listings under this identity
What this publisher told the store about itself, and the other listings that told it the same thing.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Sales Navigator profiles sent to MarketBetter
Starting the Sales Navigator list workflow, the extension uses your LinkedIn session to read lead/profile records, converts each into MarketBetter fields, and posts them to marketbetter.ai/api/private/plays/initiate-chrome-extension.
You start the Sales Navigator scrape-list workflow from the extension sidebar.
The extension reads Sales Navigator lead details and sends each normalized profile to MarketBetter.
| Field | Value | Why it matters | |
|---|---|---|---|
Profile identity | Alex Morgan, Head of Revenue | This identifies the LinkedIn person being added to the MarketBetter workflow. | |
LinkedIn profile URL | https://www.linkedin.com/in/alex-morgan-sales/ | This ties the record to a specific LinkedIn profile page. | |
Location | San Francisco, CA, United States | This adds city, state, and country context to the profile record. | |
Current and past roles | VP Sales at ExampleCo, Jan 2022 to Present | This describes the person’s job history, including titles, companies, dates, and descriptions. | |
Sales Navigator contact fields | contactInfo: { websites: ["https://example.com"] } | This can include contact information returned by LinkedIn for the profile. |
Sales Navigator profile collection and upload path
function buildProfileDetailUrl(lead) {
const urnMatch = lead.entityUrn?.match(/\(([^)]+)\)/);
if (!urnMatch) return null;
const [rawProfileId, authType, authToken] = urnMatch[1].split(",");
return `https://www.linkedin.com/sales-api/salesApiProfiles/(profileId:${rawProfileId},authType:${authType},authToken:${authToken})?decoration=${encodeURIComponent("(entityUrn,objectUrn,firstName,lastName,fullName,headline,pronoun,degree,profileUnlockInfo,location,listCount,summary,savedLead,defaultPosition,contactInfo,crmStatus,pendingInvitation,unlocked,flagshipProfileUrl,fullNamePronunciationAudio,memorialized,positions*(companyName,current,new,description,endedOn,posId,startedOn,title,location,richMedia*,companyUrn~fs_salesCompany(entityUrn,name,companyPictureDisplayImage)),crmManualMatched)").replace(/\*/g,"*").replace(/~/g,"~").replace(/\(/g,"%28").replace(/\)/g,"%29")}`
}function transformSalesNavProfileToStandardFormat(detailData) {
if (!detailData) return null;
try {
const locationParts = (detailData.location || "").split(", "),
location = {
city: locationParts[0] || "",
state: locationParts[1] || "",
country: locationParts[2] || locationParts[1] || ""
},
currentPosition = detailData.defaultPosition || detailData.positions?.[0] || {},
currentRole = {
title: currentPosition.title || "",
company: currentPosition.companyName || "",
duration: {
start_date: formatLinkedInDate(currentPosition.startedOn),
end_date: currentPosition.current ? "Present" : formatLinkedInDate(currentPosition.endedOn),
total_duration: ""
},
location: {
city: currentPosition.location ? currentPosition.location.split(", ")[0] || "" : location.city,
state: currentPosition.location ? currentPosition.location.split(", ")[1] || "" : location.state,
country: currentPosition.location ? currentPosition.location.split(", ")[2] || "" : location.country
},
description: currentPosition.description || ""
},
previousCompanies = (detailData.positions || []).filter(pos => !pos.current).map(pos => pos.companyName).filter(Boolean),
experience = (detailData.positions || []).map(pos => ({
title: pos.title || "",
company: pos.companyName || "",
company_urn: pos["companyUrn~fs_salesCompany"]?.entityUrn || null,
position_id: pos.posId || null,
recently_added: pos.new || !1,
duration: {
start_date: formatLinkedInDate(pos.startedOn),
end_date: pos.current ? "Present" : formatLinkedInDate(pos.endedOn),
total_duration: ""
},
location: {
city: pos.location && pos.location.split(", ")[0] || "",
state: pos.location && pos.location.split(", ")[1] || "",
country: pos.location && pos.location.split(", ")[2] || ""
},
description: pos.description || ""
}));
return {
profile: {
name: detailData.fullName || `${detailData.firstName||""} ${detailData.lastName||""}`.trim(),
headline: detailData.headline || "",
profile_picture_url: "",
profile_url: normalizeLinkedInUrl(detailData.flagshipProfileUrl || ""),
location: location,
member_urn: extractProfileIdFromEntityUrn(detailData.entityUrn) || detailData.objectUrn || null,
entity_urn: detailData.entityUrn || null,
contact_info: detailData.contactInfo || {},
pronoun: detailData.pronoun || null,
connections: "",
degree_connection: formatDegree(detailData.degree),
unlocked: detailData.unlocked || !1,
pending_invitation: detailData.pendingInvitation || !1,
saved_lead: detailData.savedLead || !1,
memorialized: detailData.memorialized || !1,
premium_status: !1,
...detailData._fromFallback ? {
_partial: !0
} : {}
},
current_role: currentRole,
previous_companies: previousCompanies,
about: {
summary: detailData.summary || ""
},
experience: experience,
education: [],
skills: [],
languages: []
}
} catch (error) {
return null
}
}if ("SCRAPE_SALESNAV_DETAILED" === request.action) return (async () => {
const scrapeSessionId = startScrapeSession();
let workingAudienceId = request.workflowData?.audienceId,
profilesSent = 0,
profilesSkipped = 0,
profilesInserted = 0,
profilesFailed = 0;
const listName = request.workflowData?.listName || "",
resumeCheck = await checkExistingAudienceMembers(listName),
existingMemberUrns = new Set(resumeCheck.existingMemberUrns || []);
resumeCheck.audienceId && !workingAudienceId && (workingAudienceId = resumeCheck.audienceId), existingMemberUrns.size, startScrapeKeepalive(), scrapeSalesNavLeads({
getLatestCaptures: () => ({
url: latestCapturedPeopleSearchUrl,
headers: latestCapturedSalesApiHeaders
}),
tabId: request.tabId ?? sender?.tab?.id,
startPage: request.startPage || 1,
endPage: request.endPage || null,
updatePopupSyncState: updatePopupSyncState,
shouldSkipLead: existingMemberUrns.size > 0 ? lead => lead.objectUrn && existingMemberUrns.has(lead.objectUrn) : null,
onProfileScraped: async lead => {
if (!lead || !lead.detail) return;
const standardProfile = transformSalesNavProfileToStandardFormat(lead.detail);
if (standardProfile && request.workflowData) {
updatePopupSyncState(`UPLOADING_PROFILE (${profilesSkipped+profilesSent+1})`);
const requestBody = {
...request.workflowData,
audienceId: workingAudienceId,
category: "sales_navigator",
profileData: [standardProfile]
};
try {
const postResult = await postProfileWithRetry(requestBody, {
attemptLabel: `${profilesSkipped+profilesSent+profilesFailed+1}`,
updatePopupSyncState: updatePopupSyncState
});
if (!postResult.success) {
profilesFailed++;
const failureMessage = describeProfilePostFailure(postResult),
error = new Error(isMarketBetterAuthFailure(postResult) ? failureMessage : `Profile upload failed after retry: ${failureMessage}`);
throw error.code = postResult.code, error.status = postResult.status, error
}
postResult.data?.audienceId && (workingAudienceId = postResult.data.audienceId), null != postResult.data?.insertedCount && (profilesInserted += postResult.data.insertedCount), profilesSent++
} catch (postErr) {
throw postErr
}
}
},
onProfileSkipped: (lead, index, total) => {
profilesSkipped++, updatePopupSyncState(`SKIPPED_PROFILE ${index+1}/${total}`)
}
}).then(async rawData => {
if (stopScrapeKeepalive(), !isScrapeSessionActive(scrapeSessionId)) return;
const withDetail = rawData.filter(lead => lead.detail),
duplicateCount = (withDetail.filter(lead => !lead.detail._fromFallback), withDetail.filter(lead => lead.detail._fromFallback), Math.max(profilesSent - profilesInserted, 0)),
success = 0 === profilesFailed && (profilesSent > 0 || profilesSkipped > 0);
let statusParts = profilesInserted > 0 ? [`${profilesInserted} new profiles added`] : ["no new profiles added"];
duplicateCount > 0 && statusParts.push(`${duplicateCount} duplicates skipped`), profilesSkipped > 0 && statusParts.push(`${profilesSkipped} already existed`), profilesFailed > 0 && statusParts.push(`${profilesFailed} failed`);
let postResult = {
success: success,
message: success ? `Scrape complete: ${statusParts.join(", ")}` : `Sales Navigator scrape failed: ${statusParts.join(", ")}. Please retry.`
};
request.workflowData || (postResult = {
success: !1,
message: "No workflow data provided"
}), chrome.storage.local.set({
lastScrapeResult: {
success: postResult.success,
profileCount: profilesInserted,
failedCount: profilesFailed,
message: postResult.message,
timestamp: Date.now()
}
}, () => {
updatePopupSyncState("NONE")
});
const result = {
success: postResult.success,
profileCount: profilesInserted,
failedCount: profilesFailed,
message: postResult.message
};
popupPort && popupPort.postMessage({
type: "scrapeResult",
...result
}), sendResponse(result)
}).catch(err => {
if (stopScrapeKeepalive(), !isScrapeSessionActive(scrapeSessionId)) return;
const aborted = err.message && err.message.includes("stopped by user"),
result = aborted ? {
success: !1,
aborted: !0,
message: "Scraping stopped."
} : {
success: !1,
failedCount: profilesFailed,
code: err.code || (/MarketBetter session expired|sign in to MarketBetter/i.test(err.message || "") ? "MARKETBETTER_AUTH_REQUIRED" : void 0),
status: err.status,
message: err.message || "Detailed scraping failed. Please try again."
},
finalizeSyncState = () => updatePopupSyncState("NONE");
aborted ? chrome.storage.local.remove(["lastScrapeResult", "activeScrapeContext"], finalizeSyncState) : chrome.storage.local.set({
lastScrapeResult: {
success: !1,
failedCount: profilesFailed,
code: result.code,
status: result.status,
message: result.message,
timestamp: Date.now()
}
}, finalizeSyncState), popupPort && popupPort.postMessage({
type: "scrapeResult",
...result
}), sendResponse(result)
})
})(), !0;async function triggerWorkflowAPI(requestData) {
try {
const data = await chrome.storage.local.get("authToken");
if (!data.authToken) {
const error = new Error("Please sign in to MarketBetter and retry.");
throw error.code = "MARKETBETTER_AUTH_REQUIRED", error
}
const response = await fetch(`${API_URL}/private/plays/initiate-chrome-extension`, {
method: "POST",
headers: {
Authorization: `Bearer ${data.authToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(requestData)
});
if (401 === response.status || 403 === response.status) {
await chrome.storage.local.remove("authToken");
const error = new Error("MarketBetter session expired. Please sign in again and retry.");
throw error.status = response.status, error.code = "MARKETBETTER_AUTH_REQUIRED", error
}
if (!response.ok) {
let errorMessage = `Failed to trigger workflow (${response.status})`;
try {
const errorData = await response.json();
errorData.message && (errorMessage = errorData.message)
} catch (e) {}
const error = new Error(errorMessage);
throw error.status = response.status, error
}
const result = await response.json();
if (!1 === result?.success) {
const resultStatus = result.status || result.statusCode || response.status,
resultCode = result.code || (401 === resultStatus || 403 === resultStatus ? "MARKETBETTER_AUTH_REQUIRED" : void 0);
return "MARKETBETTER_AUTH_REQUIRED" === resultCode && await chrome.storage.local.remove("authToken"), {
success: !1,
data: result,
status: resultStatus,
code: resultCode,
message: result.message || "MarketBetter rejected the profile upload."
}
}
return {
success: !0,
data: result,
status: response.status
}
} catch (error) {
return {
success: !1,
message: error.message,
status: error.status,
code: error.code
}
}
}- www.linkedin.com
Source of the Sales Navigator list and profile API responses read with the signed-in LinkedIn session.
- www.marketbetter.ai
Receives the normalized profileData array through the Chrome-extension workflow endpoint.
LinkedIn page text sent when adding GTM tasks
Adding a GTM task from a LinkedIn page, the extension clones the page body, strips non-content elements, keeps up to 50KB text, and sends it with the URL and profile fields to marketbetter.ai/api/private/gtm-tasks/create-from-extension
You click Add to Tasks while viewing LinkedIn content.
The extension reads visible page text, combines it with profile fields and the page URL, and sends the task payload to MarketBetter.
| Field | Value | Why it matters | |
|---|---|---|---|
Visible LinkedIn page text | Alex Morgan posted about expanding the revenue team this quarter. (illustrative) | This can include text visible on the LinkedIn page at the time you create the task. | |
LinkedIn page URL | https://www.linkedin.com/in/alex-morgan-sales/ | This identifies the exact LinkedIn page where the task was created. | |
Profile fields | Alex Morgan, Head of Revenue, ExampleCo | This identifies the person attached to the GTM task. | |
Task options and note | includeNote: true; sendAsSequence: false; note: Follow up after conference | This records whether a note or sequence was selected and can include the note text you entered. |
LinkedIn task payload construction and submission path
const submitBtn = dropdown.querySelector("#addToTasksBtn");
submitBtn && submitBtn.addEventListener("click", async () => {
const note = noteInput?.value?.trim() || "",
includeNote = "true" === includeNoteCheckbox?.dataset.checked,
sendAsSequence = "true" === sendAsSequenceCheckbox?.dataset.checked;
submitBtn.disabled = !0, submitBtn.style.opacity = "0.6", submitBtn.textContent = "Adding...";
try {
const nameParts = (profileInfo.name || "").trim().split(/\s+/),
firstName = nameParts[0] || "",
lastName = nameParts.slice(1).join(" ") || "",
rawPageContent = extractPageContentAsText();
let linkedinUrl = profileInfo.profileUrl || window.location.href.split("?")[0];
linkedinUrl.endsWith("/") || (linkedinUrl += "/");
const requestBody = {
linkedinUrl: linkedinUrl,
profileData: {
name: profileInfo.name || "",
firstName: firstName,
lastName: lastName,
title: profileInfo.headline || "",
company: profileInfo.company || "",
linkedinUrl: linkedinUrl
},
note: includeNote ? note : "",
includeNote: includeNote,
sendAsSequence: sendAsSequence,
rawPageContent: rawPageContent
},
response = await chrome.runtime.sendMessage({
action: "createGtmTask",
data: requestBody
});
if (!response || !response.success) throw new Error(response?.message || "Failed to add task");
submitBtn.style.opacity = "1", submitBtn.style.background = "#2e7d32", submitBtn.textContent = "Added to Tasks!", setTimeout(() => {
dropdown._cleanup?.(), dropdown.remove()
}, 1500)
} catch (err) {
submitBtn.disabled = !1, submitBtn.style.opacity = "1", submitBtn.style.background = "#c62828", submitBtn.textContent = err.message || "Failed", setTimeout(() => {
submitBtn.style.background = "#094ffe", submitBtn.textContent = "Add to Tasks"
}, 2e3)
}
});async function createGtmTaskAPI(requestData) {
try {
const data = await chrome.storage.local.get("authToken");
if (!data.authToken) throw new Error("Please log in to MarketBetter extension");
const response = await fetch(`${API_URL}/private/gtm-tasks/create-from-extension`, {
method: "POST",
headers: {
Authorization: `Bearer ${data.authToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(requestData)
});
if (401 === response.status) throw await chrome.storage.local.remove("authToken"), new Error("Session expired. Please log in again.");
if (!response.ok) {
let errorMessage = `Failed to add task (${response.status})`;
try {
const errorData = await response.json();
errorData.message && (errorMessage = errorData.message)
} catch (e) {}
throw new Error(errorMessage)
}
return {
success: !0,
data: await response.json()
}
} catch (error) {
return {
success: !1,
message: error.message
}
}
}- www.marketbetter.ai
Receives the LinkedIn URL, profileData, task options, note, and rawPageContent for task creation.
LinkedIn conversation and search data requested through Voyager APIs
When you use marketbetter's LinkedIn workflow, it can call LinkedIn Voyager APIs for conversations, profile search, and your account URN, using your session to return data beyond the current page.
A user action starts the LinkedIn workflow.
The extension calls LinkedIn Voyager APIs for messaging conversations, profile search, blended search, and the current user's account URN.
| Field | Value | Why it matters | |
|---|---|---|---|
Your LinkedIn browsing context | linkedin.com/search/results/people/?keywords=example (illustrative) | Shows which LinkedIn conversation or profile-search context the workflow is requesting through LinkedIn's APIs. | |
LinkedIn page and profile content | Jane Doe, jane-doe, urn:li:fs_miniProfile:12345, Product Lead (illustrative) | Can include conversation participant fields and profile search result details such as names, public identifiers, entity URNs, and headlines. |
Observed during dynamic analysis: runtime messages route to functions that fetch LinkedIn conversation participants and search profiles by name, and those functions use LinkedIn session CSRF context for Voyager API requests.
+3 more findings not shown