Cookbook
View as Markdown

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

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:read and leads:write in VICTORIA_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 with first_name, last_name, and email or linkedin_url, plus any other lead fields.
  • Somewhere to keep refill-state.json between 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

  1. GET /v1/campaigns lists campaigns; only is_active ones are checked.
  2. GET /v1/campaigns/{campaign_id}/queue reports backlog, daily_capacity and days_of_runway (backlog divided by capacity). A campaign under MIN_DAYS needs (MIN_DAYS − days_of_runway) × daily_capacity more leads. A campaign with days_of_runway: null can't send at all (no active sender or no capacity), so adding leads wouldn't help; it's reported and skipped.
  3. 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 an Idempotency-Key derived from the campaign and the lead.
  4. 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));

Run it with the list file:

node refill.mjs leads.json
python refill.py leads.json

It 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: 201 created, 200 an existing lead enrolled, 409 LEAD_ALREADY_IN_CAMPAIGN, 409 LEAD_SUPPRESSED (your Do Not Contact list), 400 VALIDATION_ERROR, or 403 TRIAL_LEAD_CAP_REACHED on 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_CAMPAIGN and counts as skipped. That's expected the first time a list and a campaign overlap.
  • Nothing here activates or pauses a campaign.

Next steps