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 oneref, so refs no longer tell records apart. - With
hash, it becomes the same token every time, so a record keeps itsreffrom one call to the next and asenderRefstill equals the sender’sref. An address gives the token itsemailoraddressfield 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,subjectandtextare masked by their field, when they are strings. A value of another type becomesnull.- 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:
clientsaddsclient, your own label. It passes through.identityaddspersonKeyandpersonKeys, which hold the email address and the profile slug.redactmasks the part afteremail:orlinkedin:when that field is being masked:email:[redacted], or withhashemail:redacted:and 16 hex characters, the same token the lead’semailgets.outbound.person()is refused asUnsupportedwhileredactmasksemailorlinkedin, because it links records by those identifiers and would see none. Thecross_channel_leadtool is not offered then. With afieldslist that leaves both out,person()works as usual.insightsand 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. |