People and account insights
Reference for the seven insights about people (personSummary, accountSummary, leadCollisions, suppressionCheck, droppedReplies, hotLeads, personalizationGaps), with every input, what the answer holds and a sample that runs.
Seven insights about the people in your campaigns. Each is a method on outbound.insights once the insights plugin is added. What every answer carries (data, meta.complete, meta.notes) and how inputs are checked is on the overview.
People are matched by exact identifier only: an email address, lowercased, or a LinkedIn profile. Nothing is matched by name.
The data the samples run on
Every sample on this page runs on the in-memory adapter with this data: one client on an email tool with three campaigns and on a LinkedIn tool with one. It runs at a fixed clock, 2026-11-14 12:00 UTC. See how the samples are run.
// Made-up data for one client, "acme", on two tools: an email tool with three
// campaigns and a LinkedIn tool with one. Ana is in three of the four.
import type { MemoryData } from "outbound-sdk/memory";
const ANA_PROFILE = "https://www.linkedin.com/in/ana-ruiz-example";
const HAL_PROFILE = "https://www.linkedin.com/in/hal-voss-example";
export const MAIL: MemoryData = {
blocklist: [
{ id: "block_gus", value: "gus@example.com" },
{ id: "block_domain", value: "blocked.example.com" },
{ id: "block_hal", kind: "linkedin", value: HAL_PROFILE },
{ id: "block_company", kind: "company", value: "Initech" },
],
campaigns: [
{
id: "founders",
name: "SaaS founders",
steps: [
{
variants: [
{
body: "Hi {{firstName}}, I saw {{company}} is hiring. {{icebreaker}}\n\n{{senderSignature}}",
subject: "Quick question, {{firstName}}",
},
],
},
{
delayDays: 3,
variants: [
{ body: "Hi {{firstName|there}}, did my note reach you?" },
],
},
],
},
{ id: "agencies", name: "Agency owners" },
{ id: "summer", name: "Summer push", status: "completed" },
],
leads: [
{
campaignId: "founders",
company: "Northwind",
custom: { icebreaker: "Congratulations on the funding round." },
email: "ana@northwind.example.com",
engagement: {
clicks: 1,
lastRepliedAt: "2026-11-12T12:00:00.000Z",
opens: 6,
replies: 2,
stepIndex: 1,
},
firstName: "Ana",
id: "ana_founders",
interest: "Interested",
lastName: "Ruiz",
linkedinUrl: ANA_PROFILE,
status: "replied",
title: "CEO",
},
{
campaignId: "founders",
company: "Northwind",
email: "ben@northwind.example.com",
engagement: { clicks: 0, opens: 3, replies: 0 },
firstName: "Ben",
id: "ben_founders",
lastName: "Okafor",
title: "Head of Sales",
},
{
campaignId: "founders",
company: "Globex",
custom: { icebreaker: "" },
email: "cy@example.com",
id: "cy_founders",
},
{
campaignId: "founders",
custom: { icebreaker: "Your talk last month was useful." },
email: "dee@example.com",
firstName: "Dee",
id: "dee_founders",
status: "unsubscribed",
},
{
campaignId: "agencies",
email: "ana@northwind.example.com",
engagement: { opens: 2 },
id: "ana_agencies",
},
{ campaignId: "agencies", email: "cy@example.com", id: "cy_agencies" },
{
campaignId: "agencies",
email: "eli@example.com",
engagement: {
clicks: 2,
lastRepliedAt: "2026-11-07T12:00:00.000Z",
opens: 4,
replies: 1,
},
firstName: "Eli",
id: "eli_agencies",
interest: "Meeting Booked",
lastName: "Stone",
status: "replied",
},
{
campaignId: "agencies",
email: "fay@blocked.example.com",
id: "fay_agencies",
},
{ campaignId: "agencies", email: "gus@example.com", id: "gus_agencies" },
{
campaignId: "summer",
email: "ben@northwind.example.com",
id: "ben_summer",
interest: "Not Interested",
status: "completed",
},
],
messages: [
// Ana, SaaS founders: two steps sent, then two replies nobody answered.
{
campaignId: "founders",
direction: "out",
id: "m1",
leadId: "ana_founders",
sentAt: "2026-11-03T09:00:00.000Z",
stepIndex: 0,
threadId: "thread_ana",
},
{
campaignId: "founders",
direction: "out",
id: "m2",
leadId: "ana_founders",
sentAt: "2026-11-06T09:00:00.000Z",
stepIndex: 1,
threadId: "thread_ana",
},
{
campaignId: "founders",
category: "positive",
direction: "in",
id: "m3",
leadId: "ana_founders",
sentAt: "2026-11-07T10:00:00.000Z",
subject: "Re: Quick question, Ana",
threadId: "thread_ana",
},
{
campaignId: "founders",
category: "positive",
direction: "in",
id: "m4",
leadId: "ana_founders",
sentAt: "2026-11-12T12:00:00.000Z",
subject: "Re: Quick question, Ana",
threadId: "thread_ana",
},
// Ana, Agency owners: one step sent, no reply.
{
campaignId: "agencies",
direction: "out",
id: "m5",
leadId: "ana_agencies",
sentAt: "2026-11-10T09:00:00.000Z",
stepIndex: 0,
},
// Eli: replied, and was answered two hours later.
{
campaignId: "agencies",
direction: "out",
id: "m6",
leadId: "eli_agencies",
sentAt: "2026-11-05T09:00:00.000Z",
stepIndex: 0,
threadId: "thread_eli",
},
{
campaignId: "agencies",
category: "positive",
direction: "in",
id: "m7",
leadId: "eli_agencies",
sentAt: "2026-11-07T12:00:00.000Z",
threadId: "thread_eli",
},
{
campaignId: "agencies",
direction: "out",
id: "m8",
leadId: "eli_agencies",
sentAt: "2026-11-07T14:00:00.000Z",
threadId: "thread_eli",
},
// Gus: replied 16 hours ago. Unanswered, but not for a day yet.
{
campaignId: "agencies",
direction: "out",
id: "m9",
leadId: "gus_agencies",
sentAt: "2026-11-12T09:00:00.000Z",
stepIndex: 0,
threadId: "thread_gus",
},
{
campaignId: "agencies",
category: "neutral",
direction: "in",
id: "m10",
leadId: "gus_agencies",
sentAt: "2026-11-13T20:00:00.000Z",
threadId: "thread_gus",
},
// Dee unsubscribed, and Cy's mailbox sent an automatic reply.
{
campaignId: "founders",
category: "unsubscribe",
direction: "in",
id: "m11",
leadId: "dee_founders",
sentAt: "2026-11-08T09:00:00.000Z",
threadId: "thread_dee",
},
{
campaignId: "founders",
category: "neutral",
direction: "in",
id: "m12",
isAuto: true,
leadId: "cy_founders",
sentAt: "2026-11-09T09:00:00.000Z",
threadId: "thread_cy",
},
],
};
export const LINKEDIN: MemoryData = {
campaigns: [
{ channel: "linkedin", id: "li_founders", name: "LinkedIn founders" },
],
leads: [
{ campaignId: "li_founders", id: "ana_linkedin", linkedinUrl: ANA_PROFILE },
{ campaignId: "li_founders", id: "hal_linkedin", linkedinUrl: HAL_PROFILE },
],
messages: [
// Ana: replied on LinkedIn and was answered the same day.
{
campaignId: "li_founders",
direction: "out",
id: "d1",
leadId: "ana_linkedin",
sentAt: "2026-11-04T09:00:00.000Z",
},
{
campaignId: "li_founders",
direction: "in",
id: "d2",
leadId: "ana_linkedin",
sentAt: "2026-11-05T09:00:00.000Z",
},
{
campaignId: "li_founders",
direction: "out",
id: "d3",
leadId: "ana_linkedin",
sentAt: "2026-11-05T15:00:00.000Z",
},
// Hal: replied three days ago, and nothing was sent after.
{
campaignId: "li_founders",
direction: "out",
id: "d4",
leadId: "hal_linkedin",
sentAt: "2026-11-10T12:00:00.000Z",
},
{
campaignId: "li_founders",
direction: "in",
id: "d5",
leadId: "hal_linkedin",
sentAt: "2026-11-11T12:00:00.000Z",
},
],
};
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { LINKEDIN, MAIL } from "./seed.ts";
// Two tools behind one instance. `name` is the provider each record carries.
export const outbound = createOutbound({
adapter: combine([
memory({ data: MAIL, name: "mailtool", workspaceId: "acme" }),
memory({ data: LINKEDIN, name: "linkedintool", workspaceId: "acme" }),
]),
plugins: [insights()],
});
personSummary
Everything about one person across campaigns and channels: each campaign they are in, how many messages they were sent, whether and how often they replied, which steps they replied to, and their interest label.
| Input | Default | Range | What it is |
|---|---|---|---|
person |
required | at least 3 characters | An email address or a LinkedIn profile URL. Anything else is Invalid. |
max_campaigns |
20 | 1 to 50 | The most campaigns to report on. |
data (PersonSummary):
-
found:falsewhen no record matched. -
identifiers: every exact identifier the records carry, asemail:andlinkedin:keys. -
campaigns: one entry per campaign, withcampaignName,campaignStatus,channel,provider,lead,leadStatus,interest(the vendor’s own label),messagesSent,replies,replied,repliedSteps(counted from 0),lastStep,firstContactedAt,lastContactedAtandlastRepliedAt. -
totals:campaigns,campaignsReplied,campaignReplyRate(campaigns they replied in, divided by campaigns that sent them at least one message),messagesSent,repliesandbyChannel. -
Needs:
leads.lookup,leads.timeline. -
Costs: one lookup per identifier, then up to 2 requests per campaign (the lead’s timeline and the campaign).
-
Notes it can add: a thread held fewer sent messages than the lead’s last step, so messages sent is counted from the last step.
-
Does not: match by name, or find a LinkedIn record from an email address unless some tool holds both for that person.
repliedStepsis empty when the vendor ties a reply to no step.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.personSummary({
person: "ana@northwind.example.com",
});
console.log("identifiers:", data.identifiers.join(", "));
for (const entry of data.campaigns) {
console.log(
`${entry.campaignName} (${entry.channel}): sent ${entry.messagesSent}, replies ${entry.replies}, replied to steps [${entry.repliedSteps.join(", ")}], interest ${entry.interest}`
);
}
console.log("by channel:", data.totals.byChannel);
console.log(
`replied in ${data.totals.campaignsReplied} of ${data.totals.campaigns} campaigns (rate ${data.totals.campaignReplyRate})`
);
identifiers: email:ana@northwind.example.com, linkedin:ana-ruiz-example
SaaS founders (email): sent 2, replies 2, replied to steps [1], interest Interested
Agency owners (email): sent 1, replies 0, replied to steps [], interest null
LinkedIn founders (linkedin): sent 2, replies 1, replied to steps [], interest null
by channel: {
email: { messagesSent: 3, replies: 2 },
linkedin: { messagesSent: 2, replies: 1 }
}
replied in 2 of 3 campaigns (rate 0.6667)
The email address found the LinkedIn record because the lead in “SaaS founders” carries both the address and the profile. The LinkedIn messages in this data carry no step, so repliedSteps is empty there.
accountSummary
Everyone at one company, across campaigns and tools, by exact email domain.
| Input | Default | Range | What it is |
|---|---|---|---|
domain |
required | at least 3 characters | The company’s domain, an email address at it, or a URL on it. |
allow_free_domains |
false |
boolean | Accept an email provider domain such as gmail.com, which is not one company. |
max_leads |
1,000 | 1 to 20,000 | The most leads to read in all. |
max_campaigns |
20 | 1 to 200 | The most campaigns to read leads from, when the adapter cannot list every lead at once. |
positive_labels |
Interested, Meeting Booked, Meeting Completed |
at least 1 character each | Vendor interest labels that count as interested, matched without regard to case. |
data (AccountSummary):
-
domain: the domain that was matched. -
people: one entry per email address, withname,title,company,campaigns(refs),replied,interested(nullwhen they carry no label),opens,clicksandrepliesadded up over their records,lastRepliedAt, andrecords(one per campaign). Sorted by most recent reply. -
totals:people,records,campaigns,replied,interested,leadsReadandleadsWithoutEmail. -
lastReply: the most recent reply with a known time, ornull. -
Needs:
leads.list. Without it,leads.byCampaign, and the insight reads campaign by campaign. With neither it isUnsupported. -
Costs: one request per page of leads read.
max_leadssets the ceiling. -
Notes it can add: the tool reports no per-lead open, click or reply counts, so those are
null; a cap was reached. On a combined instance the note names only the tools whose records carry no counts, and is left out when every tool’s records carry them: records from a tool that reports counts keep them. -
Does not: find a lead that has only a LinkedIn profile (they are counted in
leadsWithoutEmail), or treat a subdomain as the same company. It reads leads and filters them here, so it costs the same whether the company has one lead or fifty.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.accountSummary({
domain: "northwind.example.com",
});
for (const person of data.people) {
console.log(
`${person.name} (${person.title}): ${person.campaigns.length} campaigns, replied ${person.replied}, interested ${person.interested}, opens ${person.opens}, replies ${person.replies}`
);
}
console.log("totals:", data.totals);
console.log("last reply:", data.lastReply?.email, "at", data.lastReply?.at);
Ana Ruiz (CEO): 2 campaigns, replied true, interested true, opens 8, replies 2
Ben Okafor (Head of Sales): 2 campaigns, replied false, interested false, opens 3, replies 0
totals: {
campaigns: 3,
interested: 1,
leadsRead: 12,
leadsWithoutEmail: 2,
people: 2,
records: 4,
replied: 1
}
last reply: ana@northwind.example.com at 2026-11-12T12:00:00.000Z
Ben is interested: false because his only label, Not Interested, is not a positive one. A person with no label at all is null.
leadCollisions
People who are in two or more campaigns at once, so they get overlapping outreach.
| Input | Default | Range | What it is |
|---|---|---|---|
statuses |
["active"] |
at least one of active, paused, draft, completed, archived, unknown |
Campaign statuses to consider. |
max_campaigns |
20 | 1 to 200 | The most campaigns to read. |
max_leads_per_campaign |
500 | 1 to 20,000 | The most leads to read from each campaign. |
data (LeadCollisions):
-
collisions: one entry per person, withidentifiers,campaignCount,stillActive(campaigns in which the lead’s own status is stillactive),scope(across_toolsorwithin_tool),providers,channels,campaignsand afindingsentence. Most campaigns first. -
totals:collisions,acrossTools,withinTool,campaignsRead,leadsReadandleadsWithoutIdentifier. -
Needs:
leads.byCampaign. -
Costs: one request per page of campaigns, plus one per page of each campaign’s leads.
-
Notes it can add: a cap was reached.
-
Does not: count the same person twice in one campaign as a collision, or look at campaigns outside
statuses. A lead with neither an email address nor a profile cannot be matched and is counted inleadsWithoutIdentifier.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.leadCollisions({});
for (const collision of data.collisions) {
console.log(`${collision.scope}: ${collision.finding}`);
console.log(
` still active in ${collision.stillActive} of ${collision.campaignCount}:`,
collision.campaigns.map((each) => each.campaignName).join(", ")
);
}
console.log("totals:", data.totals);
across_tools: email:ana@northwind.example.com is in 3 campaigns across mailtool and linkedintool (email and linkedin).
still active in 2 of 3: SaaS founders, Agency owners, LinkedIn founders
within_tool: email:cy@example.com is in 2 mailtool campaigns at once.
still active in 2 of 2: SaaS founders, Agency owners
totals: {
acrossTools: 1,
campaignsRead: 3,
collisions: 2,
leadsRead: 11,
leadsWithoutIdentifier: 0,
withinTool: 1
}
Ben is also in “Summer push”, but that campaign is completed, so it is not read.
suppressionCheck
Leads sitting in campaigns although they are on the block list.
| Input | Default | Range | What it is |
|---|---|---|---|
statuses |
["active"] |
at least one campaign status | Campaign statuses to check. |
max_block_entries |
2,000 | 1 to 100,000 | The most block list entries to read. |
max_campaigns |
20 | 1 to 200 | The most campaigns to check. |
max_leads_per_campaign |
500 | 1 to 20,000 | The most leads to read from each campaign. |
data (SuppressionCheck):
-
violations: one entry per lead, withlead,email,linkedinUrl,leadStatus,campaign,campaignName,entry,entryValue,matchedBy(email,domainorlinkedin),sameTooland afindingsentence. Leads stillactivefirst. -
byCampaign: every campaign checked withleadsReadandviolations, most violations first. -
totals:violations,byMatch,campaignsRead,leadsRead,blockEntriesReadandblockEntriesNotChecked. -
Needs:
blocklist,leads.byCampaign. -
Costs: one request per page of block entries, plus one per page of each campaign’s leads.
-
Notes it can add: a cap was reached.
-
Does not: match a company name or an entry of the vendor’s kind
other. Those are counted inblockEntriesNotChecked. It reports a lead once, by its most specific match: the address, then the profile, then the domain. It removes nobody from a campaign.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.suppressionCheck({});
for (const violation of data.violations) {
console.log(
`${violation.finding} (matched by ${violation.matchedBy}, same tool: ${violation.sameTool})`
);
}
console.log("totals:", data.totals);
https://www.linkedin.com/in/hal-voss-example is in "LinkedIn founders" but the block list holds the linkedin https://www.linkedin.com/in/hal-voss-example. (matched by linkedin, same tool: false)
fay@blocked.example.com is in "Agency owners" but the block list holds the domain blocked.example.com. (matched by domain, same tool: true)
gus@example.com is in "Agency owners" but the block list holds the email gus@example.com. (matched by email, same tool: true)
totals: {
blockEntriesNotChecked: 1,
blockEntriesRead: 4,
byMatch: { domain: 1, email: 1, linkedin: 1 },
campaignsRead: 3,
leadsRead: 11,
violations: 3
}
The block list is the email tool’s. On a combined instance an entry on one tool’s list is also matched against the other tools’ leads, and marked sameTool: false: that tool does not know about the entry and will keep sending.
droppedReplies
Replies from prospects that nobody answered, longest waiting first.
| Input | Default | Range | What it is |
|---|---|---|---|
since |
null |
an ISO 8601 date, or date and time with offset | Only replies received at or after this. null means 14 days before now. |
older_than_hours |
24 | 0 or more | A reply counts as dropped only after waiting this long without an answer. |
categories |
["positive", "neutral", "unknown"] |
at least one reply category | Reply categories that need an answer. |
include_auto |
false |
boolean | Also check replies the vendor marks as automatic. |
max_replies |
50 | 1 to 1,000 | The most replies to read. |
data (DroppedReplies):
-
dropped: one entry per conversation, withreply(the oldest unanswered reply),lead,campaign,channel,provider,category,subject,receivedAt,hoursWaiting,unansweredReplies,checkedBy(threadortimeline) and afindingsentence. -
totals:repliesRead,skippedByCategory,skippedAuto,conversations,answered,dropped,tooRecentandunchecked. -
sinceandolderThanHours: what was used. -
Needs:
replies.list. To check a conversation it also needsmessages.listwithmessages.byThreadorleads.timeline. -
Costs: one request per page of replies, plus one per conversation checked. At most about
max_repliesrequests. -
Notes it can add: the adapter cannot filter replies by date, so the newest were read and filtered here; the adapter can read neither a conversation nor a timeline, so no reply could be checked; a cap was reached.
-
Does not: report a reply it could not check as dropped. Those are counted in
totals.unchecked. “Answered” means a message was sent after the reply in the same conversation. It does not read what the message said.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.droppedReplies({});
for (const reply of data.dropped) {
console.log(
`${reply.lead}: ${reply.finding} Unanswered replies: ${reply.unansweredReplies}. Checked by ${reply.checkedBy}.`
);
}
console.log("since:", data.since);
console.log("totals:", data.totals);
mailtool:acme:ana_founders: A positive reply has waited 170 hours with nothing sent after it. Unanswered replies: 2. Checked by timeline.
linkedintool:acme:hal_linkedin: An unknown reply has waited 72 hours with nothing sent after it. Unanswered replies: 1. Checked by timeline.
since: 2026-10-31T12:00:00.000Z
totals: {
answered: 2,
conversations: 5,
dropped: 2,
repliesRead: 8,
skippedAuto: 1,
skippedByCategory: 1,
tooRecent: 1,
unchecked: 0
}
Eight replies were read. One was an unsubscribe and one was automatic. Of the five conversations left, two were answered, one is 16 hours old and so too recent, and two are dropped. Both were checked by timeline because a combined instance cannot read a thread by its id.
hotLeads
Who to call first: leads ranked by a score built from what the vendor reports.
| Input | Default | Range | What it is |
|---|---|---|---|
campaign |
null |
a campaign ref | Rank one campaign’s leads. null ranks every lead the adapter lists. |
limit |
20 | 1 to 500 | How many leads to return. |
max_leads |
500 | 1 to 20,000 | The most leads to read and score. |
positive_labels |
Interested, Meeting Booked, Meeting Completed |
at least 1 character each | Interest labels that count as positive. |
weight_interest |
50 | 0 or more | Points for carrying a positive interest label. |
weight_reply |
10 | 0 or more | Points for each reply, up to count_cap. |
weight_click |
5 | 0 or more | Points for each click, up to count_cap. |
weight_open |
1 | 0 or more | Points for each open, up to count_cap. |
weight_recency |
20 | 0 or more | Points for a reply received right now. |
recency_days |
14 | 1 or more | The recency points fade evenly to none over this many days. |
count_cap |
5 | 1 or more, whole | The most replies, clicks or opens that earn points, so 40 opens do not outrank a reply. |
data (HotLeads):
-
leads: highest score first, only leads that scored above 0. Each haslead,email,linkedinUrl,name,title,company,campaign,status,interest,score,parts(the points per signal:interest,replies,clicks,opens,recency), the rawreplies,clicksandopens,lastRepliedAtand areasonsentence. -
totals:leadsRead,scored,hot,withoutDataandexcluded. -
Needs:
leads.list, orleads.byCampaignwhencampaignis given. Withoutcampaignon an adapter that cannot list every lead it isUnsupported. -
Costs: one request per page of leads read.
max_leadssets the ceiling. -
Notes it can add: the tool reports no per-lead open, click or reply counts, so scores rest on the interest label and the lead’s status; a cap was reached. On a combined instance the note names only the tools whose leads carry no counts.
-
Does not: predict anything. The score is a sum you can recompute from
parts. A part that isnullmeans the vendor does not report that signal. A lead with no label and no engagement data is left out and counted inwithoutData, never scored 0. Unsubscribed and bounced leads are left out and counted inexcluded.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.hotLeads({ limit: 3 });
for (const lead of data.leads) {
console.log(`${lead.score} ${lead.email}: ${lead.reason}`);
console.log(" parts:", lead.parts);
}
console.log("totals:", data.totals);
97.1429 ana@northwind.example.com: marked Interested, replied 2 time(s), last reply 2 day(s) ago, 1 click(s), 6 open(s).
parts: { clicks: 5, interest: 50, opens: 5, recency: 17.1429, replies: 20 }
84 eli@example.com: marked Meeting Booked, replied 1 time(s), last reply 7 day(s) ago, 2 click(s), 4 open(s).
parts: { clicks: 10, interest: 50, opens: 4, recency: 10, replies: 10 }
3 ben@northwind.example.com: 3 open(s).
parts: { clicks: 0, interest: null, opens: 3, recency: null, replies: 0 }
totals: { excluded: 1, hot: 4, leadsRead: 12, scored: 5, withoutData: 6 }
Ana’s 6 opens earn 5 points, the cap. Her reply two days ago earns 20 x (1 - 2/14) = 17.1429 recency points.
personalizationGaps
Leads in one campaign who will get broken copy because a merge variable has no value for them.
| Input | Default | Range | What it is |
|---|---|---|---|
campaign |
required | a campaign ref | The campaign to check. |
limit |
100 | 1 to 5,000 | How many affected leads to list. The counts cover all of them. |
max_leads |
500 | 1 to 20,000 | The most leads to check. |
data (PersonalizationGaps):
-
campaign: the ref that was checked. -
variables: one entry per variable in the copy, withname,source(builtin,customorunknown),gap,steps,uses(step, variant, field, and whether that use has a fallback),leadsWithoutValueandleadsAffected. Most leads affected first. -
leads: the leads missing at least one variable, withlead,email,linkedinUrl,statusandmissing. Most missing first, up tolimit. -
unknownVariables: variables no lead has a value for and that are not built in. -
totals:steps,variables,leadsRead,leadsAffectedandaffectedRate. -
Needs:
sequence,leads.byCampaign. -
Costs: one request for the sequence, plus one per page of leads.
-
Notes it can add: the sequence uses no variables, so no lead was read; a cap was reached.
-
Does not: render the copy. It finds
{{variable}},{variable}and%variable%in subjects and bodies, and checks each lead for a value: first name, last name, company, title, email, or a custom field of the same name without regard to case, spaces, underscores or hyphens. A variable with a fallback in every use, such as{{firstName|there}}, is not a gap. A variable no lead has, such as a sender signature the tool fills in, is listed asunknownand not counted against leads: check those by hand.
import { outbound } from "./outbound.ts";
const { data } = await outbound.insights.personalizationGaps({
campaign: "mailtool:acme:founders",
});
for (const variable of data.variables) {
console.log(
`${variable.name} (${variable.source}): steps [${variable.steps.join(", ")}], gap ${variable.gap}, leads affected ${variable.leadsAffected}`
);
}
for (const lead of data.leads) {
console.log(`${lead.email} is missing ${lead.missing.join(", ")}`);
}
console.log("unknown:", data.unknownVariables);
console.log("totals:", data.totals);
icebreaker (custom): steps [0], gap true, leads affected 2
company (builtin): steps [0], gap true, leads affected 1
firstName (builtin): steps [0, 1], gap true, leads affected 1
senderSignature (unknown): steps [0], gap false, leads affected null
cy@example.com is missing firstName, icebreaker
ben@northwind.example.com is missing icebreaker
dee@example.com is missing company
unknown: [ 'senderSignature' ]
totals: {
affectedRate: 0.75,
leadsAffected: 3,
leadsRead: 4,
steps: 2,
variables: 4
}
firstName is a gap although step 1 writes {{firstName|there}}, because step 0 uses it with no fallback. Cy has an icebreaker field, but it is empty, which counts as no value.