# 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.

Endpoints used:

- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`POST /v1/leads`](https://docs.versionseven.ai/api-reference/leads/create-lead) Create a lead

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`](https://docs.versionseven.ai/api-reference/leads/create-lead). 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](https://docs.versionseven.ai/guides/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`](https://docs.versionseven.ai/api-reference/campaigns/list-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:

```json
[
  {
    "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`](https://docs.versionseven.ai/api-reference/leads/create-lead) for every field.

## The script

**Node.js**

```javascript
// 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);
```

**Python**

```python
# add_contacts.py
import hashlib
import json
import os
import sys
import time

import requests

API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
PAUSE_SECONDS = 0.65  # about 90 requests a minute
MAX_ATTEMPTS = 5


def call(method, path, body=None, headers=None):
    """Sends a request, waiting and retrying on 429, 503 and IDEMPOTENCY_IN_PROGRESS."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        response = requests.request(
            method,
            f"{API_URL}{path}",
            headers={**HEADERS, **(headers or {})},
            json=body,
            timeout=30,
        )
        try:
            data = response.json()
        except ValueError:
            data = {}
        retryable = (
            response.status_code in (429, 503)
            or data.get("error") == "IDEMPOTENCY_IN_PROGRESS"
        )
        if not retryable or attempt == MAX_ATTEMPTS:
            return response.status_code, data
        time.sleep(float(response.headers.get("Retry-After") or 2**attempt))


def find_campaign(name):
    offset = 0
    while True:
        status, data = call("GET", f"/campaigns?limit=500&offset={offset}")
        if status != 200:
            sys.exit(f"Listing campaigns failed: {status} {data.get('error')}")
        for campaign in data["campaigns"]:
            if campaign["name"] == name:
                return campaign
        if not data["has_more"]:
            sys.exit(f'No campaign named "{name}"')
        offset += data["count"]


def to_lead(contact):
    lead = {}
    for field in ("first_name", "last_name", "email", "company", "title"):
        if contact.get(field):
            lead[field] = contact[field].strip()
    # LinkedIn URLs are matched exactly, so send them in one consistent form.
    if contact.get("linkedin_url"):
        lead["linkedin_url"] = contact["linkedin_url"].strip().rstrip("/")
    return lead


def main():
    campaign_name, contacts_file = sys.argv[1], sys.argv[2]
    campaign = find_campaign(campaign_name)
    with open(contacts_file, encoding="utf-8") as file:
        contacts = json.load(file)
    counts = {"created": 0, "enrolled": 0, "skipped": 0, "invalid": 0, "failed": 0}

    for contact in contacts:
        body = {"campaign_id": campaign["id"], "lead": to_lead(contact)}
        # The same campaign and lead always make the same key.
        serialized = json.dumps(body, sort_keys=True).encode()
        idempotency_key = hashlib.sha256(serialized).hexdigest()
        status, data = call("POST", "/leads", body, {"Idempotency-Key": idempotency_key})
        lead = body["lead"]
        who = lead.get("email") or lead.get("linkedin_url") or "(no email or LinkedIn URL)"
        error = data.get("error")

        if status == 201:
            counts["created"] += 1
            print(f"created  {who} → lead {data['lead_id']}")
        elif status == 200:
            counts["enrolled"] += 1
            print(f"enrolled {who} → existing lead {data['lead_id']}")
        elif error in ("LEAD_ALREADY_IN_CAMPAIGN", "LEAD_ALREADY_EXISTS"):
            counts["skipped"] += 1
            print(f"skipped  {who}: {error}")
        elif error == "VALIDATION_ERROR":
            counts["invalid"] += 1
            errors = (data.get("details") or {}).get("errors", [])
            problems = "; ".join(f"{e['field']} {e['message']}" for e in errors)
            print(f"invalid  {who}: {problems}")
        elif error == "CAMPAIGN_NOT_FOUND":
            sys.exit(f"Campaign {campaign['id']} no longer exists")
        else:
            counts["failed"] += 1
            print(f"failed   {who}: {status} {error} (request_id {data.get('request_id')})")
        time.sleep(PAUSE_SECONDS)

    print(counts)


if __name__ == "__main__":
    main()
```

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

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

## What each response means

| Response | What the script does |
| - | - |
| `201` | A new lead was created and enrolled. The script prints its `lead_id`. |
| `200` | The contact was already a lead in your organization, and that lead was enrolled. `lead_created` is `false`. |
| `409 LEAD_ALREADY_IN_CAMPAIGN` | Skips the contact: its lead is already in this campaign. |
| `409 LEAD_ALREADY_EXISTS` | Skips 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_ERROR` | Skips the contact and prints each problem from `details.errors`, such as a missing `last_name` or a malformed `email`. |
| `404 CAMPAIGN_NOT_FOUND` | Stops, because the campaign was deleted while the script ran. |
| `429 RATE_LIMITED` or `503` | Waits for `Retry-After` seconds when the response has one, and retries up to 5 attempts. See [Rate limits](https://docs.versionseven.ai/guides/rate-limits). |
| `409 IDEMPOTENCY_IN_PROGRESS` | Waits 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}`](https://docs.versionseven.ai/api-reference/leads/update-lead).

## 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](https://docs.versionseven.ai/guides/idempotency).

## Next steps

- When the campaign's sequence is ready, activate it with [`PATCH /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/update-campaign) and `{"is_active": true}`. A sequence with blank steps answers `409 CAMPAIGN_NOT_READY`, with each problem in `details.errors`.
- [Post prospect replies to Slack](https://docs.versionseven.ai/cookbook/post-replies-to-slack), to hear when the new leads reply.
