---
title: redact
description: 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.
sidebar:
  label: redact
related:
  - /docs/tools/untrusted-content
  - /docs/plugins/identity
  - /docs/plugins/fixtures
---

`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

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

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

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

```text title="Output"
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`](/docs/api/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](/docs/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`](/docs/insights) and the method-only plugins (`exporter`, `sync`, `reconcile`, `benchmarks`) read through the instance, so they receive masked records.

## When it fails

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

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