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

redact

Mask emails, names, companies, LinkedIn URLs, custom fields, subjects and bodies in everything an instance returns, and know exactly which fields it does not touch.

redact() from outbound-sdk/redact masks personal fields in the records an instance returns, before they reach a caller, an agent or a report. It always sets raw to null, because the vendor’s payload holds everything the normalized fields were taken from. It fails closed: a record it does not recognise is refused, never passed through. List it first, so it is the outermost layer.

Sample

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

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

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

const leads = await outbound.leads.list({ limit: 1 });
const [lead] = leads.items;

console.log({
  company: lead?.company,
  custom: lead?.custom,
  email: lead?.email,
  firstName: lead?.firstName,
  lastName: lead?.lastName,
  linkedinUrl: lead?.linkedinUrl,
  raw: lead?.raw,
  ref: lead?.ref,
});

const messages = await outbound.messages.list({ limit: 1 });
const [message] = messages.items;

console.log({ sentAt: message?.sentAt, subject: message?.subject });

const senders = await outbound.senders.list({ limit: 1 });
const [sender] = senders.items;

console.log({ address: sender?.address, name: sender?.name });

// Counts and campaign names are not personal data and are left alone.
const campaigns = await outbound.campaigns.list({ limit: 1 });
const [campaign] = campaigns.items;
const stats = await outbound.campaign("cmp_founders").stats();

console.log(campaign?.name, "sent", stats.rows[0]?.sent);

console.log("messages.body:", outbound.capabilities["messages.body"]);
{
  company: '[redacted]',
  custom: { phone: '[redacted]' },
  email: '[redacted]',
  firstName: '[redacted]',
  lastName: '[redacted]',
  linkedinUrl: '[redacted]',
  raw: null,
  ref: 'memory:agency:lead_jane'
}
{ sentAt: '2026-11-10T10:00:00.000Z', subject: '[redacted]' }
{ address: '[redacted]', name: '[redacted]' }
Acme: SaaS founders sent 200
messages.body: false

Options

Option Type Default Meaning
fields RedactField[] all seven What to mask: "email", "name", "company", "linkedin", "custom", "subject", "body".
strategy "mask" | "hash" "mask" mask replaces a value with [redacted]. hash replaces it with redacted: and 16 hex characters derived from the value, so the same value always gives the same token and people can still be counted and joined.

A null value stays null under both strategies. The subpath also exports ALL_FIELDS and MASK (the string [redacted]).

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

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

// Mask who the person is, keep where they work, and keep people countable.
const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    redact({ fields: ["email", "name", "linkedin"], strategy: "hash" }),
  ],
});

for await (const lead of outbound.leads.listAll({ max: 2 })) {
  console.log(lead.email, "|", lead.firstName, "|", lead.company);
}

// The same address always becomes the same token.
const again = await outbound.lead("lead_jane").get();

console.log(again.email);

// Bodies are not in `fields`, so they can still be asked for.
console.log("messages.body:", outbound.capabilities["messages.body"]);
redacted:86e0b9e56c17cc4d | redacted:4f23798d92708359 | Northwind
redacted:7451c54295882560 | redacted:4ecde249d747d51d | Contoso
redacted:86e0b9e56c17cc4d
messages.body: full

The token is a hash of the value with no secret mixed in. Anyone who can guess an address can compute its token and confirm the guess. Treat hashed output as pseudonymous, not anonymous.

What it masks

Record Masked, by field Left as it is
Lead email (email), firstName and lastName (name), company (company), linkedinUrl (linkedin), every value in custom (custom). Custom field names are kept. title, status, tags, interest, engagement, dates
Message, reply subject (subject), bodyText and bodyHtml (body) Dates, direction, category
Sender address (email), linkedinUrl (linkedin), name (name) Status, limits, warmup
Block list entry value, when the entry is an email address or a LinkedIn profile A blocked domain, and any other kind
Stats row Nothing but its scopeRef (see below). It holds counts only. Everything else
Campaign, workspace, sequence, settings, sender health, list, tag, webhook Nothing but ids and refs (see below) Everything except raw
Every record An email address (email) or a LinkedIn profile URL (linkedin) inside ref, sourceId or any field whose name ends in Ref or Refs An id the vendor made up, such as a number or a UUID

Every record except a stats row has raw set to null, whatever fields says. Sequence steps are template text and are not masked.

Ids and refs

Some vendors use personal data as an id. Instantly addresses a mailbox by its email address, so a sender’s ref and sourceId hold it, and so does every senderRef that points at the sender. HeyReach identifies a lead by its LinkedIn profile URL, which then sits in the lead’s ref and in the leadRef of its messages.

redact masks that part of ref, sourceId and every *Ref field, on records and on webhook events, when the matching field (email or linkedin) is being masked:

  • With mask, the address or the URL becomes [redacted]. Two senders then share one ref, so refs no longer tell records apart.
  • With hash, it becomes the same token every time, so a record keeps its ref from one call to the next and a senderRef still equals the sender’s ref. An address gives the token its email or address field gets.

A masked ref cannot be passed back to outbound.sender(ref) or outbound.lead(ref): it no longer holds the vendor’s id.

What stays: an id that is not an address or a profile URL is not personal data and is left as it is. A bare profile slug with no URL around it cannot be told from such an id, and stays too.

What it turns off

Capability Becomes When Why
messages.body false fields includes "body" (the default) Asking for bodies while they are being removed is a contradiction, so the call is refused up front.
native false Always A native read returns the vendor’s payload untouched.

outbound.raw has no capability of its own, and returns the same untouched payload. It is refused as Unsupported on an instance with redact, whether or not the adapter has native reads.

It changes nothing in meta.

What it adds

Member Holds
outbound.redacted { fields, strategy }: the fields being masked, sorted, and "mask" or "hash". Other plugins and the tools read it to know what they cannot see.

Webhook events

The plugin also has an event hook, which runs when you pass the instance’s plugins to parse() from outbound-sdk/webhooks (see webhooks). It sets the event’s raw to null and rewrites data:

  • leadEmail, leadName, linkedinUrl, subject and text are masked by their field, when they are strings. A value of another type becomes null.
  • Any other key keeps a plain value (string, number, boolean, null).
  • Any other key holding an object or an array becomes [redacted], because it could hold anything.

With other plugins

List redact first. It masks what the layers inside it return, so cache, failover and fixtures inside it see, and store, unmasked records. To keep masked data in a store, list that plugin before redact.

redact masks the fields it knows and passes every other field through:

  • clients adds client, your own label. It passes through.
  • identity adds personKey and personKeys, which hold the email address and the profile slug. redact masks the part after email: or linkedin: when that field is being masked: email:[redacted], or with hash email:redacted: and 16 hex characters, the same token the lead’s email gets.
  • outbound.person() is refused as Unsupported while redact masks email or linkedin, because it links records by those identifiers and would see none. The cross_channel_lead tool is not offered then. With a fields list that leaves both out, person() works as usual.
  • insights and the method-only plugins (exporter, sync, reconcile, benchmarks) read through the instance, so they receive masked records.

When it fails

import { createOutbound, OutboundError } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { redact } from "outbound-sdk/redact";

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

const describe = (error: OutboundError): string =>
  `${error.code} - ${error.message}`;

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

// 1. Asking for bodies while bodies are masked is refused before any read.
try {
  await outbound.messages.list({ include: ["body"] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("bodies:", describe(error));
  }
}

// 2. A plugin listed twice is refused when the instance is built.
try {
  createOutbound({
    adapter: memory({ data: seed }),
    plugins: [redact(), redact({ strategy: "hash" })],
  });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("twice:", describe(error));
  }
}

// 3. A record redact does not recognise is refused, not passed through.
//    This inner plugin returns something that is not an SDK record.
const stranger: OutboundPlugin = {
  name: "stranger",
  wrap: async (operation, next) => {
    const output = await next(operation);

    // SAFETY: deliberately not a record, to show what redact does with one.
    return { ...output, items: [{ ref: "x" } as never] };
  },
};

try {
  await createOutbound({
    adapter: memory({ data: seed }),
    plugins: [redact(), stranger],
  }).leads.list();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("unknown record:", describe(error));
  }
}
bodies: Unsupported - Reading message bodies is not available: the redact plugin turned it off (messages.body)
twice: Invalid - The redact plugin is listed twice
unknown record: Unsupported - The redact plugin does not recognise this record, so it will not pass it through
What happened What you get What to do
A read asks for bodies (include: ["body"]) Unsupported, before any request. The message names the plugin. Leave bodies out, or construct redact with fields that do not include "body".
A native read, or any outbound.raw request Unsupported, before any request. The message names the plugin. Use a second instance without redact for those reads, and handle its output as unmasked.
outbound.person() while email or linkedin is masked Unsupported, before any read Look the person up on an instance without redact, or leave both fields out of fields.
redact listed twice Invalid, when the instance is built List it once.
A layer inside returns something that is not an SDK record Unsupported. Nothing is returned. Fix the plugin that produced it.

Was this page helpful?