Keep campaigns fed from your own lead list
A scheduled script that checks each active campaign's days of runway and, when it drops below a threshold, enrols the next batch from your own list, once.
Last updated
- cron
- GitHub Actions
A campaign that runs out of leads stops quietly. This recipe is a script to run daily: it reads each active campaign's runway, and when a campaign has fewer than a week of leads left, it enrols the next batch from a list you keep, sized to bring the runway back up. The list here is a JSON file; the part that reads it is one function, which you swap for a database query, a sheet or an export.
Before you start
- An API key with
campaigns:readandleads:writeinVICTORIA_API_KEY. See Authentication. - A lead list per campaign. The script reads
leads.json: an object keyed by campaign id, each a list of leads withfirst_name,last_name, andemailorlinkedin_url, plus any other lead fields. - Somewhere to keep
refill-state.jsonbetween runs: it records which leads have already been sent, so the list can stay as it is. - Node.js 18 or later, or Python 3.10 or later with
requests.
How it works
GET /v1/campaignslists campaigns; onlyis_activeones are checked.GET /v1/campaigns/{campaign_id}/queuereportsbacklog,daily_capacityanddays_of_runway(backlog divided by capacity). A campaign underMIN_DAYSneeds(MIN_DAYS − days_of_runway) × daily_capacitymore leads. A campaign withdays_of_runway: nullcan't send at all (no active sender or no capacity), so adding leads wouldn't help; it's reported and skipped.- That many leads are taken from the campaign's list, skipping ones already sent, and enrolled one by one with
POST /v1/leads, each with anIdempotency-Keyderived from the campaign and the lead. - The state file records each lead sent, whatever the API answered, so a lead that's invalid or suppressed isn't retried every day.
The list
{
"550e8400-e29b-41d4-a716-446655440000": [
{ "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" }
]
}The script
// refill.mjs
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const MIN_DAYS = Number(process.env.MIN_DAYS ?? 7);
const STATE_FILE = process.env.STATE_FILE ?? "refill-state.json";
const [listFile = "leads.json"] = process.argv.slice(2);
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 === 5) return { status: response.status, data };
await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
}
}
// Your lead source. Replace the body of this function with a query, a sheet read or an export.
async function leadsFor(campaignId) {
const lists = JSON.parse(await readFile(listFile, "utf8"));
return lists[campaignId] ?? [];
}
const leadKey = (lead) => (lead.email ?? lead.linkedin_url ?? "").trim().toLowerCase().replace(/\/+$/, "");
const state = await readFile(STATE_FILE, "utf8").then(JSON.parse).catch(() => ({}));
const summary = { checked: 0, refilled: 0, enrolled: 0, skipped: 0 };
const { data: list } = await call("GET", "/campaigns?limit=500");
for (const campaign of list.campaigns.filter((candidate) => candidate.is_active)) {
summary.checked++;
const { status, data: queue } = await call("GET", `/campaigns/${campaign.id}/queue`);
if (status !== 200) {
console.error(`${campaign.name}: queue answered ${status} ${queue.error}`);
continue;
}
if (queue.days_of_runway === null) {
console.log(`${campaign.name}: can't send (${queue.warnings.join("; ")}); adding leads wouldn't help`);
continue;
}
if (queue.days_of_runway >= MIN_DAYS) {
console.log(`${campaign.name}: ${queue.days_of_runway} days of runway, nothing to do`);
continue;
}
const needed = Math.ceil((MIN_DAYS - queue.days_of_runway) * queue.daily_capacity);
const sent = new Set(state[campaign.id] ?? []);
const batch = (await leadsFor(campaign.id)).filter((lead) => leadKey(lead) && !sent.has(leadKey(lead))).slice(0, needed);
console.log(`${campaign.name}: ${queue.days_of_runway} days of runway, wants ${needed} leads, list has ${batch.length} new`);
summary.refilled++;
for (const lead of batch) {
const body = { campaign_id: campaign.id, lead };
const idempotencyKey = createHash("sha256").update(`${campaign.id}:${leadKey(lead)}`).digest("hex");
const { status, data } = await call("POST", "/leads", body, { "Idempotency-Key": idempotencyKey });
sent.add(leadKey(lead)); // recorded whatever the answer, so the lead isn't retried every day
if (status === 201 || status === 200) summary.enrolled++;
else {
summary.skipped++;
console.log(` ${leadKey(lead)}: ${status} ${data.error}`);
}
await sleep(650);
}
state[campaign.id] = [...sent];
}
await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
console.log(JSON.stringify(summary));# refill.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']}"}
MIN_DAYS = float(os.environ.get("MIN_DAYS", 7))
STATE_FILE = os.environ.get("STATE_FILE", "refill-state.json")
LIST_FILE = sys.argv[1] if len(sys.argv) > 1 else "leads.json"
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, 6):
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 == 5:
return response.status_code, data
time.sleep(float(response.headers.get("Retry-After") or 2**attempt))
def leads_for(campaign_id):
"""Your lead source. Replace this with a query, a sheet read or an export."""
with open(LIST_FILE, encoding="utf-8") as file:
return json.load(file).get(campaign_id, [])
def lead_key(lead):
return (lead.get("email") or lead.get("linkedin_url") or "").strip().lower().rstrip("/")
def main():
try:
with open(STATE_FILE, encoding="utf-8") as file:
state = json.load(file)
except (FileNotFoundError, ValueError):
state = {}
summary = {"checked": 0, "refilled": 0, "enrolled": 0, "skipped": 0}
_, listing = call("GET", "/campaigns?limit=500")
for campaign in [c for c in listing["campaigns"] if c.get("is_active")]:
summary["checked"] += 1
status, queue = call("GET", f"/campaigns/{campaign['id']}/queue")
if status != 200:
print(f"{campaign['name']}: queue answered {status} {queue.get('error')}", file=sys.stderr)
continue
if queue["days_of_runway"] is None:
print(f"{campaign['name']}: can't send ({'; '.join(queue['warnings'])}); adding leads wouldn't help")
continue
if queue["days_of_runway"] >= MIN_DAYS:
print(f"{campaign['name']}: {queue['days_of_runway']} days of runway, nothing to do")
continue
needed = int(-(-(MIN_DAYS - queue["days_of_runway"]) * queue["daily_capacity"] // 1)) # ceil
sent = set(state.get(campaign["id"], []))
batch = [lead for lead in leads_for(campaign["id"]) if lead_key(lead) and lead_key(lead) not in sent][:needed]
print(f"{campaign['name']}: {queue['days_of_runway']} days of runway, wants {needed} leads, list has {len(batch)} new")
summary["refilled"] += 1
for lead in batch:
key = hashlib.sha256(f"{campaign['id']}:{lead_key(lead)}".encode()).hexdigest()
status, data = call("POST", "/leads", {"campaign_id": campaign["id"], "lead": lead}, {"Idempotency-Key": key})
sent.add(lead_key(lead)) # recorded whatever the answer, so the lead isn't retried every day
if status in (200, 201):
summary["enrolled"] += 1
else:
summary["skipped"] += 1
print(f" {lead_key(lead)}: {status} {data.get('error')}")
time.sleep(0.65)
state[campaign["id"]] = sorted(sent)
with open(STATE_FILE, "w", encoding="utf-8") as file:
json.dump(state, file, indent=2)
print(json.dumps(summary))
if __name__ == "__main__":
main()Run it with the list file:
node refill.mjs leads.json
python refill.py leads.jsonIt prints one line per campaign and a summary such as {"checked": 2, "refilled": 1, "enrolled": 10, "skipped": 0}. Run it again and the same campaign gets nothing, because every lead in its list has been sent; add leads to the list and the next run picks them up.
Schedule it
Once a day is right for most campaigns; runway changes by a day each day. The same crontab or GitHub Actions pattern as the daily digest works, with leads.json and refill-state.json in the checkout or the cache.
For campaigns built from the Lead database
A campaign whose leads come from the in-app Lead database doesn't need this script. Save its search as the campaign's lead_database_filters and turn on its refill_policy with PATCH /v1/campaigns/{campaign_id}, and Victoria AI tops it up itself, spending credits as it does. The queue's last_refill shows the most recent top-up. Lead database covers both fields; this script is for leads you bring.
What to expect
- Each lead answers like any
POST /leads:201created,200an existing lead enrolled,409 LEAD_ALREADY_IN_CAMPAIGN,409 LEAD_SUPPRESSED(your Do Not Contact list),400 VALIDATION_ERROR, or403 TRIAL_LEAD_CAP_REACHEDon a trial. All of them are recorded as sent, so a bad row is reported once, not daily. - The batch is sized from capacity at the time of the run. A campaign whose sender is still ramping has a small
daily_capacity, so it asks for fewer leads; as the ramp lifts, later runs add more. - A lead already in the campaign (added by hand, or by another run with a different key) answers
409 LEAD_ALREADY_IN_CAMPAIGNand counts as skipped. That's expected the first time a list and a campaign overlap. - Nothing here activates or pauses a campaign.
Next steps
- Post a daily campaign digest to Slack, which flags the runway this script acts on.
- Add leads from Google Sheets to a campaign, when the list lives in a sheet.
- Lead database for the auto-refill alternative.