Find people in two campaigns at once, and leads that are on a block list
Two functions, one that lists each person who is in two or more campaigns inside a tool or across tools, and one that lists leads sitting in campaigns although a block list holds their address, domain or profile. Both report; neither removes anyone.
Two mistakes cost an agency a client: the same person getting two sequences in the same week, and someone who asked not to be contacted getting another email. Both are visible in the data before they happen again.
leadCollisionsreads the campaigns in the statuses you name and their leads, and returns each person who is in two or more of them, with the campaigns, the channel and the tool.suppressionCheckreads the block list and the same leads, and returns each lead in a campaign whose address, domain or LinkedIn profile is on the list.
Both only report. Neither removes a lead, pauses a campaign or edits a block list. People are matched by exact email address or LinkedIn profile, never by name.
Before you start
- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an API key for each tool. A read-only key is enough. No method of the SDK writes to a vendor.
What you get
2 people are in two or more active campaigns (1 across tools, 1 inside one tool)
email:raj@example.com is in 3 campaigns across mailtool and lntool (email and linkedin).
Founders Q4 (mailtool, email), lead is active
Agency owners (mailtool, email), lead is active
LinkedIn founders (lntool, linkedin), lead is active
email:nina@example.com is in 2 mailtool campaigns at once.
Founders Q4 (mailtool, email), lead is active
Agency owners (mailtool, email), lead is active
Read 10 leads in 3 campaigns. 0 had no address and no profile to match on.
4 leads are in active campaigns although they are on a block list (2 by address, 1 by domain, 1 by profile)
https://www.linkedin.com/in/pat-quinn-example is in "LinkedIn founders" but the block list holds the linkedin https://www.linkedin.com/in/pat-quinn-example.
lead is active; the entry is on this tool's own list
kim@blocked.example is in "Agency owners" but the block list holds the domain blocked.example.
lead is active; the entry is on this tool's own list
sue@example.com is in "Agency owners" but the block list holds the email sue@example.com.
lead is active; the entry is on another tool's list
leo@example.com is in "Founders Q4" but the block list holds the email leo@example.com.
lead is completed; the entry is on this tool's own list
Read 5 block list entries and 10 leads. 1 entries are of a kind that cannot be matched to a lead, such as a company name.
Put the tools behind one instance
One adapter works: every collision is then inside that tool. With two, combine puts them behind one instance and the same calls also find people who are in both.
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { emailTool, linkedinTool } from "./seed.ts";
// Two tools behind one instance. `name` stands in for the vendor: every
// record keeps it as `provider`.
export const outbound = createOutbound({
adapter: combine([
memory({ data: emailTool, name: "mailtool", workspaceId: "agency" }),
memory({ data: linkedinTool, name: "lntool", workspaceId: "agency" }),
]),
plugins: [insights()],
});
Write the two reports
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";
/** People in two or more campaigns at once, as text. */
export const collisionReport = async (
outbound: Outbound & InsightsExtension
): Promise<string> => {
const { data, meta } = await outbound.insights.leadCollisions({});
const { totals } = data;
const lines = [
`${totals.collisions} people are in two or more active campaigns ` +
`(${totals.acrossTools} across tools, ${totals.withinTool} inside one tool)`,
"",
];
// Most campaigns first. That order comes from the insight.
for (const person of data.collisions) {
lines.push(
` ${person.finding}`,
...person.campaigns.map(
(each) =>
` ${each.campaignName} (${each.provider}, ${each.channel}), lead is ${each.leadStatus}`
)
);
}
lines.push(
"",
`Read ${totals.leadsRead} leads in ${totals.campaignsRead} campaigns. ` +
`${totals.leadsWithoutIdentifier} had no address and no profile to match on.`
);
if (!meta.complete || meta.notes.length > 0) {
lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
}
return lines.join("\n");
};
/** Leads in campaigns although the block list holds them, as text. */
export const blockListReport = async (
outbound: Outbound & InsightsExtension
): Promise<string> => {
const { data, meta } = await outbound.insights.suppressionCheck({});
const { totals } = data;
const lines = [
`${totals.violations} leads are in active campaigns although they are on a block list ` +
`(${totals.byMatch.email} by address, ${totals.byMatch.domain} by domain, ${totals.byMatch.linkedin} by profile)`,
"",
];
// Leads the tool still counts as active come first.
for (const violation of data.violations) {
lines.push(
` ${violation.finding}`,
` lead is ${violation.leadStatus}; ${
violation.sameTool
? "the entry is on this tool's own list"
: "the entry is on another tool's list"
}`
);
}
lines.push(
"",
`Read ${totals.blockEntriesRead} block list entries and ${totals.leadsRead} leads. ` +
`${totals.blockEntriesNotChecked} entries are of a kind that cannot be matched to a lead, such as a company name.`
);
if (!meta.complete || meta.notes.length > 0) {
lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
}
return lines.join("\n");
};
How a collision is found:
- Only campaigns in
statusesare read. The default is["active"]. - Records that share an exact email address or LinkedIn profile are one person. A record that carries both joins the two groups: that is how Raj’s LinkedIn record is tied to his email records above.
- A person in two or more different campaigns is a collision. The same person twice in one campaign is not.
scopeisacross_toolswhen the campaigns are in more than one tool, otherwisewithin_tool.stillActivecounts the campaigns in which the lead’s own status is stillactive. A lead who finished one sequence and is active in another hasstillActive1.
How a block list match is found:
- An
emailentry matches the exact address. Adomainentry matches the part of the address after the@. Alinkedinentry matches the profile. - The most specific match is reported: the address, then the profile, then the domain.
- On several tools, an entry on one tool’s list is matched against the other tools’ leads as well, and
sameToolisfalse. Sue above is blocked in the LinkedIn tool and still in an email campaign. - Entries that are a company name, or that the adapter maps to the kind
other, cannot be matched to a lead. They are counted intotals.blockEntriesNotChecked. - A violation is reported whatever the lead’s status. Leads still
activecome first; Leo, who finished his sequence, is last.
Try it without a key
// Two made-up tools for one agency: an email tool with three campaigns and a
// LinkedIn tool with one. Each has its own block list.
import type { MemoryData } from "outbound-sdk/memory";
const RAJ_PROFILE = "https://www.linkedin.com/in/raj-mehta-example";
const PAT_PROFILE = "https://www.linkedin.com/in/pat-quinn-example";
export const emailTool: MemoryData = {
blocklist: [
{ id: "blk_leo", value: "leo@example.com" },
{ id: "blk_domain", value: "blocked.example" },
],
campaigns: [
{ id: "em_founders", name: "Founders Q4" },
{ id: "em_agencies", name: "Agency owners" },
{ id: "em_summer", name: "Summer promo", status: "paused" },
],
leads: [
// Nina is in two active email campaigns.
{ campaignId: "em_founders", email: "nina@example.com", id: "f_nina" },
{ campaignId: "em_agencies", email: "nina@example.com", id: "a_nina" },
// Raj is in two email campaigns. One record carries his profile, which
// ties him to the LinkedIn tool.
{
campaignId: "em_founders",
email: "raj@example.com",
id: "f_raj",
linkedinUrl: RAJ_PROFILE,
},
{ campaignId: "em_agencies", email: "raj@example.com", id: "a_raj" },
// Zoe is in one active campaign and one paused one.
{ campaignId: "em_founders", email: "zoe@example.com", id: "f_zoe" },
{ campaignId: "em_summer", email: "zoe@example.com", id: "s_zoe" },
// Leo finished the sequence. His address is on the block list.
{
campaignId: "em_founders",
email: "leo@example.com",
id: "f_leo",
status: "completed",
},
// Kim's domain is blocked.
{ campaignId: "em_agencies", email: "kim@blocked.example", id: "a_kim" },
// Sue is blocked in the LinkedIn tool only.
{ campaignId: "em_agencies", email: "sue@example.com", id: "a_sue" },
],
};
export const linkedinTool: MemoryData = {
blocklist: [
{ id: "blk_pat", kind: "linkedin", value: PAT_PROFILE },
{ id: "blk_sue", value: "sue@example.com" },
// A company name cannot be matched to a lead.
{ id: "blk_company", kind: "company", value: "Initech Example" },
],
campaigns: [
{ channel: "linkedin", id: "li_founders", name: "LinkedIn founders" },
],
leads: [
{ campaignId: "li_founders", id: "p_raj", linkedinUrl: RAJ_PROFILE },
{ campaignId: "li_founders", id: "p_pat", linkedinUrl: PAT_PROFILE },
],
};
import { outbound } from "./agency.ts";
import { blockListReport, collisionReport } from "./overlap.ts";
console.log(await collisionReport(outbound));
console.log(`\n${await blockListReport(outbound)}`);
That is the script that printed the output above.
Include paused campaigns
A paused campaign that will be resumed is worth checking before it is. Pass the statuses to read.
import { outbound } from "./agency.ts";
// By default only active campaigns are read. A paused campaign that will be
// resumed is worth checking too.
const { data } = await outbound.insights.leadCollisions({
statuses: ["active", "paused"],
});
for (const person of data.collisions) {
console.log(person.finding, `Still active in ${person.stillActive}.`);
}
email:raj@example.com is in 3 campaigns across mailtool and lntool (email and linkedin). Still active in 3.
email:nina@example.com is in 2 mailtool campaigns at once. Still active in 2.
email:zoe@example.com is in 2 mailtool campaigns at once. Still active in 2.
Zoe appears now. stillActive is about the lead’s status in each campaign, not the campaign’s: her record in the paused campaign is still active.
Point it at your vendor
Swap the adapters. Nothing else changes.
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { heyreach } from "outbound-sdk/heyreach";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
// Reads INSTANTLY_API_KEY and HEYREACH_API_KEY. One tool works too: pass
// its adapter alone and every collision is `within_tool`.
export const outbound = createOutbound({
adapter: combine([
instantly({ workspaceId: "agency" }),
heyreach({ workspaceId: "agency" }),
]),
// One call may spend at most 300 vendor requests.
maxRequests: 300,
plugins: [insights()],
});
A collision across tools needs a record that carries both an address and a profile. Instantly leads carry no LinkedIn profile, so between Instantly and HeyReach the link can only come from a HeyReach lead that has an email address.
What each vendor can serve
Both reports, run on the in-memory adapter limited to what each vendor’s adapter declares. This shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns.
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { blockListReport, collisionReport } from "./overlap.ts";
import { emailTool } from "./seed.ts";
for (const vendor of VENDORS) {
const outbound = createOutbound({
adapter: asVendor(vendor, emailTool, "agency"),
plugins: [insights()],
});
for (const [name, report] of [
["leadCollisions", collisionReport],
["suppressionCheck", blockListReport],
] as const) {
try {
// oxlint-disable-next-line no-await-in-loop -- one read at a time.
const text = await report(outbound);
console.log(`${vendor} ${name}: ok, ${text.split("\n").length} lines`);
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${vendor} ${name}: ${error.code}: ${error.message}`);
}
}
}
instantly leadCollisions: ok, 10 lines
instantly suppressionCheck: ok, 8 lines
smartlead leadCollisions: ok, 10 lines
smartlead suppressionCheck: ok, 8 lines
heyreach leadCollisions: ok, 10 lines
heyreach suppressionCheck: ok, 8 lines
leadCollisions needs leads.byCampaign. suppressionCheck needs that and blocklist. All three adapters declare both, so none refuses either call.
What it costs
leadCollisions: one request per page of campaigns, plus one per page of each campaign’s leads.suppressionCheck: one request per page of block list entries, plus one per page of each campaign’s leads.
The defaults read up to 20 campaigns (max_campaigns, at most 200), 500 leads from each (max_leads_per_campaign, at most 20,000) and 2,000 block list entries (max_block_entries, at most 100,000). A vendor request returns about 50 to 100 records. The two insights each read the leads, so running both reads them twice. maxRequests on the instance caps a call; past it the call fails with RateLimited instead of spending your vendor quota.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { outbound } from "./agency.ts";
import { emailTool } from "./seed.ts";
// 1. A cap stopped a campaign's leads early. A collision can hide in the
// leads that were not read, so the answer says it is incomplete.
const capped = await outbound.insights.leadCollisions({
max_leads_per_campaign: 2,
});
console.log(
"capped:",
capped.data.totals.leadsRead,
capped.data.totals.collisions,
capped.meta.complete,
capped.meta.notes
);
// 2. The same person under an address in one tool and a profile in the
// other, with no record that holds both: not matched, and not an error.
const apart = createOutbound({
adapter: memory({
data: {
campaigns: [{ id: "c1" }, { channel: "linkedin", id: "c2" }],
leads: [
{ campaignId: "c1", email: "ida@example.com", id: "l1" },
{
campaignId: "c2",
firstName: "Ida",
id: "l2",
linkedinUrl: "https://www.linkedin.com/in/ida-example",
},
// A lead with neither cannot be matched to anyone.
{ campaignId: "c2", firstName: "Ida", id: "l3" },
],
},
}),
plugins: [insights()],
});
const unmatched = await apart.insights.leadCollisions({});
console.log("no shared identifier:", unmatched.data.totals);
// 3. An empty status list is refused before any request.
try {
await outbound.insights.suppressionCheck({ statuses: [] });
} catch (error) {
if (OutboundError.is(error)) {
console.log("bad input:", error.code);
}
}
// 4. An adapter with no block list refuses suppressionCheck, and only that.
const noBlockList = createOutbound({
adapter: memory({ capabilities: { blocklist: false }, data: emailTool }),
plugins: [insights()],
});
try {
await noBlockList.insights.suppressionCheck({});
} catch (error) {
if (OutboundError.is(error)) {
console.log("no block list:", error.code, "-", error.message);
}
}
const stillRuns = await noBlockList.insights.leadCollisions({});
console.log("collisions still run:", stillRuns.data.totals.collisions);
capped: 6 2 false [ 'Stopped at 2 leads of a campaign; more exist.' ]
no shared identifier: {
acrossTools: 0,
campaignsRead: 2,
collisions: 0,
leadsRead: 3,
leadsWithoutIdentifier: 1,
withinTool: 0
}
bad input: Invalid
no block list: Unsupported - suppression_check is not available: the memory adapter does not support blocklist
collisions still run: 2
| What happened | What you get | What to do |
|---|---|---|
| A cap stopped a read | meta.complete is false and a note says which cap |
Raise the cap. A collision or a blocked lead can be among the leads not read. |
| The same person under an address in one tool and a profile in another, with no record holding both | No collision. Nothing says one was missed. | Store the address on the LinkedIn lead, or the profile on the email lead. |
| A lead with neither an address nor a profile | Counted in totals.leadsWithoutIdentifier, matched to nobody |
Nothing in the SDK can match it. |
An empty statuses, or another input out of range |
Invalid, before any request |
Fix the value. |
| The adapter has no block list read | suppressionCheck fails with Unsupported. leadCollisions still runs. |
Use an adapter that declares blocklist. |
| A rejected key | Unauthorized. It is not retried. |
Check the key and its scope. |
Limits
- “At once” means in two campaigns that are both in
statuseswhen you ask. It does not compare send dates, so it does not say the person got two messages on the same day. - No fuzzy matching.
nina@example.comandn.ortega@example.comare two people. - A domain entry matches that exact domain. A lead at a subdomain of it is not matched.
- A clean result under a cap is not a clean account. Read
meta.completebefore you report “no overlaps”. - On an
unverifiedadapter, check a finding against the vendor’s own campaign view before acting on it.