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
Endpoints used
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_KEYenvironment 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
- Find the campaign by name with
GET /v1/campaigns. - Turn each contact into a lead. A lead needs
first_name,last_name, and anemailor alinkedin_url. - Send it with an
Idempotency-Keymade from the campaign and the lead, so a retried request can't enrol the lead twice. - 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);# 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:
node add-contacts.mjs "Q3 outbound" contacts.json
python add_contacts.py "Q3 outbound" contacts.jsonWhat 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. |
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.comfindssarah.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
- When the campaign's sequence is ready, activate it with
PATCH /v1/campaigns/{campaign_id}and{"is_active": true}. A sequence with blank steps answers409 CAMPAIGN_NOT_READY, with each problem indetails.errors. - Post prospect replies to Slack, to hear when the new leads reply.