Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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: false when no record matched.

  • identifiers: every exact identifier the records carry, as email: and linkedin: keys.

  • campaigns: one entry per campaign, with campaignName, campaignStatus, channel, provider, lead, leadStatus, interest (the vendor’s own label), messagesSent, replies, replied, repliedSteps (counted from 0), lastStep, firstContactedAt, lastContactedAt and lastRepliedAt.

  • totals: campaigns, campaignsReplied, campaignReplyRate (campaigns they replied in, divided by campaigns that sent them at least one message), messagesSent, replies and byChannel.

  • 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. repliedSteps is 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, with name, title, company, campaigns (refs), replied, interested (null when they carry no label), opens, clicks and replies added up over their records, lastRepliedAt, and records (one per campaign). Sorted by most recent reply.

  • totals: people, records, campaigns, replied, interested, leadsRead and leadsWithoutEmail.

  • lastReply: the most recent reply with a known time, or null.

  • Needs: leads.list. Without it, leads.byCampaign, and the insight reads campaign by campaign. With neither it is Unsupported.

  • Costs: one request per page of leads read. max_leads sets 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, with identifiers, campaignCount, stillActive (campaigns in which the lead’s own status is still active), scope (across_tools or within_tool), providers, channels, campaigns and a finding sentence. Most campaigns first.

  • totals: collisions, acrossTools, withinTool, campaignsRead, leadsRead and leadsWithoutIdentifier.

  • 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 in leadsWithoutIdentifier.

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, with lead, email, linkedinUrl, leadStatus, campaign, campaignName, entry, entryValue, matchedBy (email, domain or linkedin), sameTool and a finding sentence. Leads still active first.

  • byCampaign: every campaign checked with leadsRead and violations, most violations first.

  • totals: violations, byMatch, campaignsRead, leadsRead, blockEntriesRead and blockEntriesNotChecked.

  • 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 in blockEntriesNotChecked. 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, with reply (the oldest unanswered reply), lead, campaign, channel, provider, category, subject, receivedAt, hoursWaiting, unansweredReplies, checkedBy (thread or timeline) and a finding sentence.

  • totals: repliesRead, skippedByCategory, skippedAuto, conversations, answered, dropped, tooRecent and unchecked.

  • since and olderThanHours: what was used.

  • Needs: replies.list. To check a conversation it also needs messages.list with messages.byThread or leads.timeline.

  • Costs: one request per page of replies, plus one per conversation checked. At most about max_replies requests.

  • 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 has lead, email, linkedinUrl, name, title, company, campaign, status, interest, score, parts (the points per signal: interest, replies, clicks, opens, recency), the raw replies, clicks and opens, lastRepliedAt and a reason sentence.

  • totals: leadsRead, scored, hot, withoutData and excluded.

  • Needs: leads.list, or leads.byCampaign when campaign is given. Without campaign on an adapter that cannot list every lead it is Unsupported.

  • Costs: one request per page of leads read. max_leads sets 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 is null means the vendor does not report that signal. A lead with no label and no engagement data is left out and counted in withoutData, never scored 0. Unsubscribed and bounced leads are left out and counted in excluded.

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, with name, source (builtin, custom or unknown), gap, steps, uses (step, variant, field, and whether that use has a fallback), leadsWithoutValue and leadsAffected. Most leads affected first.

  • leads: the leads missing at least one variable, with lead, email, linkedinUrl, status and missing. Most missing first, up to limit.

  • unknownVariables: variables no lead has a value for and that are not built in.

  • totals: steps, variables, leadsRead, leadsAffected and affectedRate.

  • 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 as unknown and 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.

Was this page helpful?