---
title: Find people in two campaigns at once, and leads that are on a block list
description: 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.
sidebar:
  label: Double-contacted leads
related:
  - /docs/insights/people
  - /docs/adapters/combine
  - /docs/guides/person-across-email-and-linkedin
  - /docs/guides/pre-launch-audit
---

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.

- `leadCollisions` reads 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.
- `suppressionCheck` reads 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.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## 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

```text title="Output"
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.

```ts title="agency.ts"
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

```ts title="overlap.ts"
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 `statuses` are 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.
- `scope` is `across_tools` when the campaigns are in more than one tool, otherwise `within_tool`.
- `stillActive` counts the campaigns in which the lead's own status is still `active`. A lead who finished one sequence and is active in another has `stillActive` 1.

How a block list match is found:

- An `email` entry matches the exact address. A `domain` entry matches the part of the address after the `@`. A `linkedin` entry 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 `sameTool` is `false`. 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 in `totals.blockEntriesNotChecked`.
- A violation is reported whatever the lead's status. Leads still `active` come first; Leo, who finished his sequence, is last.

## Try it without a key

```ts title="seed.ts"
// 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 },
  ],
};
```

```ts title="run.ts"
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.

```ts title="paused.ts"
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}.`);
}
```

```text title="Output"
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.

```ts title="outbound.ts"
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.

```ts title="vendors.ts"
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}`);
    }
  }
}
```

```text title="Output"
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

```ts title="failures.ts"
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);
```

```text title="Output"
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 `statuses` when 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.com` and `n.ortega@example.com` are 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.complete` before you report "no overlaps".
- On an `unverified` adapter, check a finding against the vendor's own campaign view before acting on it.
