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

identity

Tag every lead with person keys built from exact identifiers, and look one person up across tools with every record and one timeline.

identity() from outbound-sdk/identity links the same person across tools and campaigns. It adds personKey and personKeys to every lead, and outbound.person(identifier), which returns every record for that person and one timeline of their messages. A key comes from an exact identifier only: a lowercased email address or a cleaned LinkedIn profile URL. There is no matching on names or companies. Records are linked, never merged: each keeps its own provider, workspaceId and fields.

Sample

Two tools behind one combined instance. The email tool knows Jane by address and profile. The LinkedIn tool knows her only by profile, written differently.

import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { identity } from "outbound-sdk/identity";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// A second tool that knows Jane only by her LinkedIn profile.
const linkedinTool = memory({
  data: {
    campaigns: [{ channel: "linkedin", id: "li_founders", name: "Founders" }],
    leads: [
      {
        campaignId: "li_founders",
        firstName: "Jane",
        id: "li_jane",
        linkedinUrl: "https://linkedin.com/in/Jane-Doe-Example/?utm_source=x",
      },
    ],
    messages: [
      {
        body: "Thanks for connecting, Jane.",
        campaignId: "li_founders",
        direction: "out",
        id: "li_msg_1",
        leadId: "li_jane",
        sentAt: "2026-11-10T15:00:00.000Z",
      },
    ],
  },
  name: "linkedintool",
  workspaceId: "agency",
});

const outbound = createOutbound({
  adapter: combine([
    memory({ data: seed, workspaceId: "agency" }),
    linkedinTool,
  ]),
  plugins: [identity()],
});

// Every lead is tagged as it is read.
for await (const lead of outbound.leads.listAll()) {
  console.log(
    lead.ref,
    "personKey" in lead ? lead.personKey : "?",
    "personKeys" in lead ? lead.personKeys : "?"
  );
}

// Asked by email, found in both tools through the profile the email record carries.
const person = await outbound.person("Jane.Doe@example.com");

console.log("\nfound:", person.found);

console.log("keys:", person.keys);

console.log(
  "records:",
  person.records.map((record) => record.ref)
);

for (const message of person.timeline) {
  console.log(message.sentAt, message.provider, message.direction);
}

// No record carries this address. The link is unknown, not an error.
const nobody = await outbound.person("nobody@example.com");

console.log("\nnobody:", nobody.found, nobody.records.length);
memory:agency:lead_jane email:jane.doe@example.com [ 'email:jane.doe@example.com', 'linkedin:jane-doe-example' ]
memory:agency:lead_sam email:sam.lee@example.com [ 'email:sam.lee@example.com' ]
memory:agency:lead_priya email:priya.patel@example.com [ 'email:priya.patel@example.com' ]
memory:agency:lead_omar email:omar.haddad@example.com [ 'email:omar.haddad@example.com' ]
linkedintool:agency:li_jane linkedin:jane-doe-example [ 'linkedin:jane-doe-example' ]

found: true
keys: [ 'email:jane.doe@example.com', 'linkedin:jane-doe-example' ]
records: [ 'memory:agency:lead_jane', 'linkedintool:agency:li_jane' ]
2026-11-10T10:00:00.000Z memory out
2026-11-10T15:00:00.000Z linkedintool out
2026-11-11T08:30:00.000Z memory in

nobody: false 0

The lookup started from an email address in mixed case. The email record carried a profile URL, which led to the LinkedIn record. The timeline is the three messages to and from Jane in both tools, oldest first.

Options

identity() takes no options.

outbound.person(identifier, options?) takes:

Option Type Default Meaning
campaignIds string[] none Look only in these campaigns (refs). Each campaign’s leads are walked in full.
timeline boolean true false leaves the timeline out, which saves one read per matching record.
include ("body")[] none ["body"] asks for message text on the timeline. Without it bodyText is null. Needs the messages.body capability.

identifier is a person key (email:... or linkedin:...), an email address or a LinkedIn profile URL. Anything else throws Invalid.

How a key is built

Source Key Rule
email email:jane.doe@example.com Trimmed and lowercased. A value with no @ gives no key.
linkedinUrl linkedin:jane-doe-example The profile slug from the URL, as profileSlug from outbound-sdk returns it. A URL with no slug gives no key.

personKeys holds every key a lead has, email first. personKey is the first of them, or null for a lead with neither identifier. The subpath also exports the functions themselves: emailKey, linkedinKey, keysOf and toKey.

What it adds

Member Returns
Every lead personKey: string | null and personKeys: string[], added at run time. The exported PersonLead type describes the result, but Lead does not declare the fields, so read them with an in check.
outbound.person(identifier, options?) A Person.

A Person has:

Field Meaning
key The key that was looked up.
keys Every key found on the matching records, sorted.
found false when no record carries the key. The link is unknown, not proven absent.
records One lead record per tool or campaign the person is in.
timeline Every message to and from the person across those records, oldest first. Empty when timeline is false or the adapter does not declare leads.timeline.

How the lookup reads

person() picks one of three paths from the instance’s capabilities:

  1. The vendor can find a lead by identifier (leads.lookup), and campaignIds is not set. It asks for the person by each key. A record that carries a second key is looked up under that one too, so an email can lead to a profile and back. The vendor’s own matching is not trusted: only a record whose exact key matches is kept.
  2. Otherwise, the adapter lists all leads (leads.list), and campaignIds is not set. Every lead is walked.
  3. Otherwise, every campaign is listed and each campaign’s leads are walked. With campaignIds, only those campaigns.

On paths 2 and 3, a record that links a new key causes one more full walk, to catch records seen earlier under the other key.

Paths 2 and 3 read every lead they cover. On a large account that is many requests: pass campaignIds, or set maxRequests on the instance.

The timeline is one lead(ref).timeline() read per matching record, all started together.

What it changes

It changes no capability and nothing in meta.

With other plugins

List it after redact and before cache.

With redact on the same instance:

  • personKey and personKeys are masked with the field they came from. A key keeps its email: or linkedin: prefix, and with strategy: "hash" the same person still gets the same key.
  • person() is refused as Unsupported while redact masks email or linkedin. It links records by those identifiers, and on masked records it would link nothing. It never answers found: false for that reason.

person() reads through the instance’s public API, so cache, budget, audit and failover apply to every read it makes.

When it fails

import { createOutbound, OutboundError } from "outbound-sdk";
import { identity } from "outbound-sdk/identity";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [identity()],
});

// 1. A name is not an exact identifier, so it is refused.
try {
  await outbound.person("Jane Doe");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("a name:", error.code, "-", error.message);
  }
}

// 2. Bounded to one campaign, and without the timeline.
const bounded = await outbound.person("email:priya.patel@example.com", {
  campaignIds: ["memory:agency:cmp_founders"],
  timeline: false,
});

console.log("wrong campaign:", bounded.found);

// 3. An adapter that cannot read a lead's timeline: the records still link.
const noTimeline = createOutbound({
  adapter: memory({
    capabilities: { "leads.timeline": false },
    data: seed,
    workspaceId: "agency",
  }),
  plugins: [identity()],
});

const jane = await noTimeline.person("jane.doe@example.com");

console.log("no timeline:", jane.records.length, jane.timeline.length);
a name: Invalid - person() needs a person key, an email address or a LinkedIn profile URL
wrong campaign: false
no timeline: 1 0
What happened What you get What to do
The identifier is a name or other text Invalid, before any read Pass an email address, a profile URL or a key.
No record carries the key found: false, empty records and timeline Treat it as unknown. The person may exist under an identifier the vendor did not give you.
redact on the instance masks email or linkedin Unsupported, before any read. The message names the plugin. Use an instance without redact, or leave both fields out of its fields.
The adapter cannot read a lead’s timeline Records link, timeline is empty Check outbound.capabilities["leads.timeline"] before relying on the timeline.
A read fails part way person() throws that error. No partial answer is returned. Retry, or bound the lookup with campaignIds.

Was this page helpful?