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

Audit a campaign's copy, merge variables and settings before it launches

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.

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.

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

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

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:

// 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",
    },
  ],
};
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.

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.

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"));
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

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);
  }
}
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.

Was this page helpful?