---
title: Audit a campaign's copy, merge variables and settings before it launches
description: One function that lists what is wrong with a campaign's copy, which leads would get a blank merge variable, and which settings are risky, as a report you read before you press launch.
sidebar:
  label: Pre-launch audit
related:
  - /docs/insights/copy
  - /docs/insights/people
  - /docs/insights/senders
  - /docs/guides/sender-capacity
---

Most launch mistakes are visible before the first email goes out: a variable with one brace missing, a lead with no first name, a follow-up with a new subject that starts a second thread, a daily limit the mailboxes cannot carry. Three insights find them. `copyAudit` checks every step and variant of the sequence against fixed text rules. `personalizationGaps` checks every lead against the merge variables the copy uses. `settingsAudit` checks the schedule, limits and tracking.

It is a report. Nothing here blocks, delays or edits a campaign: the SDK is read-only, and the launch button stays in your vendor tool. The rules are fixed and no AI model reads the copy.

> **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 Instantly, Smartlead or HeyReach API key. A read-only key is enough.

## What you get

```text title="Output"
Pre-launch audit of Agency owners Q1 (draft)

Copy: 5 findings in 3 variants
  [high] step 0 B: A variable's braces are not balanced, so it sends as raw text.
  [medium] step 0 A: The copy uses 1 word(s) or phrase(s) that spam filters weigh.
  [medium] step 0 A: The body has 2 links; the limit is 1.
  [medium] step 1 A: This follow-up has a subject that differs from the first email's, so it starts a new thread instead of continuing the first.
  [low] step 1 A: The copy has 2 exclamation marks; the limit is 1.

Merge variables: 3 of 5 leads would get broken copy
  company: 2 leads affected
  firstName: 2 leads affected
  painPoint: 0 leads affected
  senderSignature: no lead has it; check it by hand
  sales@example.com is missing company, firstName
  info@example.com is missing firstName
  cara@example.com is missing company

Settings: 6 findings
  [high] The daily limit of 200 is over 50 a day for each of its 2 senders (100).
  [high] A lead who replies keeps getting the next steps.
  [medium] A sending window is 14 hours long, over 12.
  [medium] Open tracking is on, a deliverability risk.
  [low] A sending window includes Saturday or Sunday.
  [low] A sending window has no timezone.
```

## Write the audit

```ts title="audit.ts"
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";

/**
 * Everything the three audits find in one campaign, as plain text. It
 * reports. Nothing here stops the campaign from being launched.
 */
export const preLaunchAudit = async (
  outbound: Outbound & InsightsExtension,
  id: string
): Promise<string> => {
  const campaign = await outbound.campaigns.get(id);

  const copy = await outbound.insights.copyAudit({ campaign: campaign.ref });

  const gaps = await outbound.insights.personalizationGaps({
    campaign: campaign.ref,
  });

  // settingsAudit takes statuses, not one campaign. Ask for this campaign's
  // status and pick the campaign out of the answer.
  const settings = await outbound.insights.settingsAudit({
    statuses: [campaign.status],
  });

  const own = settings.data.campaigns.find(
    (audited) => audited.campaign === campaign.ref
  );

  const lines = [
    `Pre-launch audit of ${campaign.name} (${campaign.status})`,
    "",
    `Copy: ${copy.data.findings.length} findings in ${copy.data.variantsChecked} variants`,
    ...copy.data.findings.map(
      (finding) =>
        `  [${finding.severity}] step ${finding.step} ${finding.variantKey}: ${finding.finding}`
    ),
    "",
    `Merge variables: ${gaps.data.totals.leadsAffected} of ${gaps.data.totals.leadsRead} leads would get broken copy`,
    ...gaps.data.variables.map((variable) =>
      variable.leadsAffected === null
        ? `  ${variable.name}: no lead has it; check it by hand`
        : `  ${variable.name}: ${variable.leadsAffected} leads affected`
    ),
    ...gaps.data.leads.map(
      (lead) =>
        `  ${lead.email ?? lead.lead} is missing ${lead.missing.join(", ")}`
    ),
    "",
    `Settings: ${own?.findings.length ?? 0} findings`,
    ...(own?.findings ?? []).map(
      (finding) => `  [${finding.severity}] ${finding.finding}`
    ),
  ];

  // A cap that cut a read short, or a check that was skipped, is said here.
  const notes = [
    ...copy.meta.notes,
    ...gaps.meta.notes,
    ...settings.meta.notes,
  ];

  if (notes.length > 0) {
    lines.push(
      "",
      "Read this before relying on the audit:",
      ...notes.map((note) => `  - ${note}`)
    );
  }

  return lines.join("\n");
};
```

What each call checks:

- **`copyAudit`** takes one campaign. For each variant it looks for a subject over 60 characters, a body over 150 words, more than one link, a word from a short spam list, a `{{ }}` variable that is empty or has unbalanced braces, more than one word of four or more letters in capitals, more than one exclamation mark, an image or tracking pixel, a first email with no subject, and a follow-up whose subject differs from the first email's. A LinkedIn campaign skips the email-only checks and flags a connection request over 300 characters. Every limit is an input (`max_subject_chars`, `max_body_words`, `max_links`, `spam_words` and so on). Each finding has a severity and the text that triggered it.
- **`personalizationGaps`** takes one campaign. It finds every `{{variable}}`, `{variable}` and `%variable%` in subjects and bodies and checks each lead for a value: first name, last name, company, title, email, or a custom field of the same name, ignoring case, spaces, underscores and hyphens. A variable with a fallback, such as `{{firstName|there}}`, is not a gap. A variable no lead has, such as a signature the vendor fills in, is listed in `unknownVariables` and not counted against leads.
- **`settingsAudit`** takes statuses, not a campaign, and audits every campaign in them. Its default is `["active"]`, so a draft is only looked at when you ask for `draft`. It flags no sending window, a window that includes the weekend, is longer than 12 hours or shorter than 2, a window with no timezone, open or click tracking on, a reply that does not stop the sequence, no daily limit, sends less than 5 minutes apart, an end date in the past on an active campaign, and a daily limit over 50 a day for each attached sender. The bars are inputs.

The three use different variable rules on purpose. `copyAudit` flagged `{{firstName}` in step 0 B as broken text. `personalizationGaps` does not see it as a variable at all, so that variant is not what makes a lead "affected".

## Try it without a key

This seed is one draft campaign with five leads and two mailboxes, and one running campaign the audit leaves alone:

```ts title="seed.ts"
// A made-up draft campaign for the client "acme", with the kinds of mistakes
// that are cheap to fix before launch and costly after.
import type { MemoryData } from "outbound-sdk/memory";

export const CAMPAIGN = "cmp_launch";

export const seed: MemoryData = {
  campaigns: [
    {
      id: CAMPAIGN,
      name: "Agency owners Q1",
      settings: {
        dailyLimit: 200,
        // Every day of the week, 14 hours a day, with no timezone.
        schedule: [{ days: [0, 1, 2, 3, 4, 5, 6], from: "08:00", to: "22:00" }],
        stopOnReply: false,
        trackOpens: true,
      },
      status: "draft",
      steps: [
        {
          variants: [
            {
              body: "Hi {{firstName}}, I saw {{company}} is hiring. We help with {{painPoint|your pipeline}}. Start a free trial at https://example.com/start or read https://example.com/case-study.\n\n{{senderSignature}}",
              subject: "Idea for {{company}}",
            },
            {
              body: "Hi {{firstName}, can I send you a two-line summary?",
              subject: "Idea for {{company}}",
            },
          ],
        },
        {
          delayDays: 3,
          variants: [
            {
              body: "Hi {{firstName|there}}, did my last note reach you?",
              subject: "Following up!!",
            },
          ],
        },
      ],
    },
    // A campaign that is already running. The audit leaves it alone.
    { id: "cmp_running", name: "SaaS founders", status: "active" },
  ],
  leads: [
    {
      campaignId: CAMPAIGN,
      company: "Northwind",
      custom: { painPoint: "slow follow-up" },
      email: "ana@example.com",
      firstName: "Ana",
      id: "lead_1",
    },
    {
      campaignId: CAMPAIGN,
      company: "Contoso",
      email: "ben@example.com",
      firstName: "Ben",
      id: "lead_2",
    },
    // No first name.
    {
      campaignId: CAMPAIGN,
      company: "Fabrikam",
      email: "info@example.com",
      id: "lead_3",
    },
    // No company.
    {
      campaignId: CAMPAIGN,
      email: "cara@example.com",
      firstName: "Cara",
      id: "lead_4",
    },
    // Neither.
    { campaignId: CAMPAIGN, email: "sales@example.com", id: "lead_5" },
  ],
  senders: [
    {
      address: "sam@example.com",
      campaignIds: [CAMPAIGN],
      dailyLimit: 30,
      id: "snd_1",
    },
    {
      address: "kim@example.com",
      campaignIds: [CAMPAIGN],
      dailyLimit: 30,
      id: "snd_2",
    },
  ],
};
```

```ts title="run.ts"
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

import { preLaunchAudit } from "./audit.ts";
import { CAMPAIGN, seed } from "./seed.ts";

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

console.log(await preLaunchAudit(outbound, CAMPAIGN));
```

That is the script that printed the audit above.

## Point it at your vendor

Swap the adapter. Nothing else changes.

```ts title="outbound.ts"
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { smartlead } from "outbound-sdk/smartlead";

// Reads SMARTLEAD_API_KEY. Swap the import for instantly(). HeyReach can
// serve the copy and merge-variable audits, not the settings audit.
export const outbound = createOutbound({
  adapter: smartlead({ workspaceId: "acme" }),
  // One audit may spend at most 80 vendor requests.
  maxRequests: 80,
  plugins: [insights()],
});
```

To make it a gate, call the function from your own launch checklist or CI job and decide there what counts as a failure, for example any `high` finding. The decision and the gate are yours: the SDK reports and never stops a launch.

## What each vendor can serve

Each insight, 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 type { Vendor } from "../_shared/as-vendor.ts";
import { CAMPAIGN, seed } from "./seed.ts";

const outcome = async (
  name: string,
  read: Promise<object>
): Promise<string> => {
  try {
    await read;

    return `  ${name}: ok`;
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    return `  ${name}: ${error.code}: ${error.message}`;
  }
};

const tryAs = async (vendor: Vendor): Promise<string> => {
  const { insights: run } = createOutbound({
    adapter: asVendor(vendor, seed, "acme"),
    plugins: [insights()],
  });

  const lines = await Promise.all([
    outcome("copyAudit", run.copyAudit({ campaign: CAMPAIGN })),
    outcome(
      "personalizationGaps",
      run.personalizationGaps({ campaign: CAMPAIGN })
    ),
    outcome("settingsAudit", run.settingsAudit({ statuses: ["draft"] })),
  ]);

  return [vendor, ...lines].join("\n");
};

const results = await Promise.all(VENDORS.map(tryAs));

console.log(results.join("\n"));
```

```text title="Output"
instantly
  copyAudit: ok
  personalizationGaps: ok
  settingsAudit: ok
smartlead
  copyAudit: ok
  personalizationGaps: ok
  settingsAudit: ok
heyreach
  copyAudit: ok
  personalizationGaps: ok
  settingsAudit: Unsupported: settings_audit is not available: the heyreach adapter does not support campaigns.settings
```

`settingsAudit` needs `campaigns.settings`. The HeyReach adapter declares it `false`: HeyReach's API returns no schedule, start or end date for a campaign, and sending limits belong to the LinkedIn account. On HeyReach, run the other two and check settings in HeyReach itself.

## What it costs in vendor requests

- `copyAudit`: two reads, the campaign and its sequence.
- `personalizationGaps`: one read for the sequence, then one per page of leads, up to `max_leads` (default 500). When the sequence uses no variables, no lead is read.
- `settingsAudit`: one listing of campaigns per status, then for each campaign one read for its settings and, when the adapter can list a campaign's senders, one listing of them. It reads up to `max_campaigns` (default 25) campaigns in the statuses you pass, not only yours, so on a workspace with many drafts most of its cost is other campaigns.
- The function above adds one read of the campaign.

A read can be more than one vendor request. On Instantly, reading a campaign is three requests, and listing a campaign's senders reads every mailbox in the workspace at two requests per page.

## 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 { preLaunchAudit } from "./audit.ts";
import { CAMPAIGN, seed } from "./seed.ts";

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

// 1. A campaign that does not exist.
try {
  await preLaunchAudit(outbound, "cmp_missing");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("unknown campaign:", error.code, "-", error.message);
  }
}

// 2. More leads than the cap. The counts cover the leads read, and the
//    answer says it is not complete.
const capped = await outbound.insights.personalizationGaps({
  campaign: CAMPAIGN,
  max_leads: 2,
});

console.log(
  "capped:",
  capped.meta.complete,
  `${capped.data.totals.leadsAffected} of ${capped.data.totals.leadsRead}`,
  capped.meta.notes
);

// 3. A status with no campaign in it. settingsAudit audits nothing and has
//    nothing to report, which is not the same as a clean campaign.
const paused = await outbound.insights.settingsAudit({ statuses: ["paused"] });

console.log("no campaign in that status:", paused.data.totals);

// 4. The default status is `active`, so a draft is not looked at unless asked.
const byDefault = await outbound.insights.settingsAudit({});

console.log(
  "default statuses:",
  byDefault.data.campaigns.map((campaign) => campaign.name)
);

// 5. An adapter that cannot read campaign settings refuses the settings
//    audit before any request.
const noSettings = createOutbound({
  adapter: memory({
    capabilities: { "campaigns.settings": false },
    data: seed,
  }),
  plugins: [insights()],
});

try {
  await noSettings.insights.settingsAudit({ statuses: ["draft"] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("no settings:", error.code, "-", error.message);
  }
}
```

```text title="Output"
unknown campaign: NotFound - Campaign cmp_missing was not found
capped: false 0 of 2 [ 'Stopped at 2 leads of a campaign; more exist.' ]
no campaign in that status: { campaigns: 0, clean: 0, high: 0, low: 0, medium: 0, withFindings: 0 }
default statuses: [ 'SaaS founders' ]
no settings: Unsupported - settings_audit is not available: the memory adapter does not support campaigns.settings
```

| What happened | What you get | What to do |
| --- | --- | --- |
| A campaign that does not exist | `NotFound` | Check the id. |
| More leads than `max_leads` | `meta.complete` is `false`, the counts cover the leads read, and a note says where it stopped | Raise `max_leads` (up to 20,000). "0 of 2 affected" says nothing about lead 3. |
| No campaign in the statuses passed | `totals.campaigns` is `0` and no findings | Not a clean result. Pass the campaign's own status, as the function above does. |
| `settingsAudit` with no input | Only `active` campaigns are audited | Pass `statuses` for a draft or paused campaign. |
| The adapter cannot read campaign settings | `Unsupported`, before any request | Run the other two audits; check settings in the vendor tool. |
| A variant whose body the vendor does not return | The variant is counted in `variantsWithoutBody`, its body checks are skipped and a note says so | Read that variant by hand. |

## Limits

- `settingsAudit` cannot be pointed at one campaign. The function above filters its answer; the vendor requests for the other campaigns in that status are still spent.
- A setting the vendor reports nothing for is skipped, except a missing window, timezone or daily limit, which cannot be told apart from one that is not set and is reported as a finding.
- `copyAudit` is a fixed list of text rules. It does not judge whether the copy is good, and the default spam list is 14 words and phrases. Pass your own with `spam_words`.
- `personalizationGaps` checks the leads in the campaign when you run it. Leads added afterwards are not covered: run it again after the last import.
- A campaign with zero findings has passed these checks, nothing more.
