Cookbook
View as Markdown

Add CRM contacts to a campaign

A script that adds contacts exported from your CRM to a Victoria AI campaign, safe to re-run, handling existing leads, invalid rows and rate limits.

Last updated

This recipe takes a list of contacts from your CRM, such as an export or the result of your CRM's own API, and adds each one to a Victoria AI campaign with POST /v1/leads. Run it as often as you like: a contact that's already a lead is enrolled rather than duplicated, and one that's already in the campaign is skipped.

Before you start

  • An API key in the VICTORIA_API_KEY environment variable. See Authentication.
  • The name of the campaign to add leads to. It can be a draft, paused or active campaign. Each lead starts in the campaign's backlog.
  • Node.js 18 or later, or Python 3.10 or later with requests.

How it works

  1. Find the campaign by name with GET /v1/campaigns.
  2. Turn each contact into a lead. A lead needs first_name, last_name, and an email or a linkedin_url.
  3. Send it with an Idempotency-Key made from the campaign and the lead, so a retried request can't enrol the lead twice.
  4. Handle the response, pausing between requests to stay under the rate limit of 100 requests a minute to one endpoint.

Contacts

The script reads a JSON file of contacts. Map your CRM's field names to these:

[
  {
    "first_name": "Sarah",
    "last_name": "Johnson",
    "email": "sarah.johnson@acmecorp.com",
    "company": "Acme Corp",
    "title": "VP of Sales"
  },
  {
    "first_name": "Marcus",
    "last_name": "Lee",
    "linkedin_url": "https://www.linkedin.com/in/marcuslee/",
    "company": "Northwind"
  }
]

A lead can also carry company_website, industry, annual_revenue, employees and custom_fields. See POST /v1/leads for every field.

The script

// add-contacts.mjs
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const PAUSE_MS = 650; // about 90 requests a minute
const MAX_ATTEMPTS = 5;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Sends a request, waiting and retrying on 429, 503 and IDEMPOTENCY_IN_PROGRESS.
async function call(method, path, body, headers = {}) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${API_URL}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
        ...headers,
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const data = await response.json().catch(() => ({}));
    const retryable =
      response.status === 429 ||
      response.status === 503 ||
      data.error === "IDEMPOTENCY_IN_PROGRESS";
    if (!retryable || attempt === MAX_ATTEMPTS) return { status: response.status, data };
    const waitSeconds = Number(response.headers.get("Retry-After")) || 2 ** attempt;
    await sleep(waitSeconds * 1000);
  }
}

async function findCampaign(name) {
  for (let offset = 0; ; ) {
    const { status, data } = await call("GET", `/campaigns?limit=500&offset=${offset}`);
    if (status !== 200) throw new Error(`Listing campaigns failed: ${status} ${data.error}`);
    const campaign = data.campaigns.find((candidate) => candidate.name === name);
    if (campaign) return campaign;
    if (!data.has_more) throw new Error(`No campaign named "${name}"`);
    offset += data.count;
  }
}

function toLead(contact) {
  const lead = {};
  for (const field of ["first_name", "last_name", "email", "company", "title"]) {
    if (contact[field]) lead[field] = contact[field].trim();
  }
  // LinkedIn URLs are matched exactly, so send them in one consistent form.
  if (contact.linkedin_url) lead.linkedin_url = contact.linkedin_url.trim().replace(/\/+$/, "");
  return lead;
}

const [campaignName, contactsFile] = process.argv.slice(2);
const campaign = await findCampaign(campaignName);
const contacts = JSON.parse(await readFile(contactsFile, "utf8"));
const counts = { created: 0, enrolled: 0, skipped: 0, invalid: 0, failed: 0 };

for (const contact of contacts) {
  const body = { campaign_id: campaign.id, lead: toLead(contact) };
  // The same campaign and lead always make the same key.
  const idempotencyKey = createHash("sha256").update(JSON.stringify(body)).digest("hex");
  const { status, data } = await call("POST", "/leads", body, {
    "Idempotency-Key": idempotencyKey,
  });
  const who = body.lead.email ?? body.lead.linkedin_url ?? "(no email or LinkedIn URL)";

  if (status === 201) {
    counts.created++;
    console.log(`created  ${who} → lead ${data.lead_id}`);
  } else if (status === 200) {
    counts.enrolled++;
    console.log(`enrolled ${who} → existing lead ${data.lead_id}`);
  } else if (data.error === "LEAD_ALREADY_IN_CAMPAIGN" || data.error === "LEAD_ALREADY_EXISTS") {
    counts.skipped++;
    console.log(`skipped  ${who}: ${data.error}`);
  } else if (data.error === "VALIDATION_ERROR") {
    counts.invalid++;
    const errors = data.details?.errors ?? [];
    const problems = errors.map((error) => `${error.field} ${error.message}`);
    console.log(`invalid  ${who}: ${problems.join("; ")}`);
  } else if (data.error === "CAMPAIGN_NOT_FOUND") {
    throw new Error(`Campaign ${campaign.id} no longer exists`);
  } else {
    counts.failed++;
    console.log(`failed   ${who}: ${status} ${data.error} (request_id ${data.request_id})`);
  }
  await sleep(PAUSE_MS);
}

console.log(counts);

Run it with the campaign's name and the contacts file:

node add-contacts.mjs "Q3 outbound" contacts.json
python add_contacts.py "Q3 outbound" contacts.json

What each response means

ResponseWhat the script does
201A new lead was created and enrolled. The script prints its lead_id.
200The contact was already a lead in your organization, and that lead was enrolled. lead_created is false.
409 LEAD_ALREADY_IN_CAMPAIGNSkips the contact: its lead is already in this campaign.
409 LEAD_ALREADY_EXISTSSkips the contact. With a campaign_id in the body, this only happens when an identical request created the lead at the same moment.
400 VALIDATION_ERRORSkips the contact and prints each problem from details.errors, such as a missing last_name or a malformed email.
404 CAMPAIGN_NOT_FOUNDStops, because the campaign was deleted while the script ran.
429 RATE_LIMITED or 503Waits for Retry-After seconds when the response has one, and retries up to 5 attempts. See Rate limits.
409 IDEMPOTENCY_IN_PROGRESSWaits and retries: an earlier attempt for the same lead is still running.

How existing leads are matched

A contact matches an existing lead when its email or its linkedin_url is already on a lead in your organization:

  • Emails are compared without regard to case or surrounding spaces, so Sarah.Johnson@acmecorp.com finds sarah.johnson@acmecorp.com.
  • LinkedIn URLs are compared exactly, so a URL with a trailing slash is a different URL. The script removes trailing slashes; whatever form you choose, use it every time.

A matched lead is enrolled as it's stored. The name, company and other fields in the request don't update it; to change them, use PATCH /v1/leads/{lead_id}.

Re-running the script

The Idempotency-Key is a SHA-256 hash of the request body: the campaign ID and the lead's fields. Running the script again with the same contacts sends the same keys, and nothing is enrolled twice:

  • While the API still remembers a key, for at least 24 hours, the request returns the first response again, with the header Idempotent-Replay: true.
  • After that, the lead is already in the campaign, so the request answers 409 LEAD_ALREADY_IN_CAMPAIGN.

When a contact's details change in your CRM, the body and so the key change too. The request then goes through as new and is matched to the existing lead by email or LinkedIn URL, instead of failing with 409 IDEMPOTENCY_KEY_REUSED. See Idempotency.

Next steps