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

Works with: cron, GitHub Actions.

Endpoints used:

- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`GET /v1/campaigns/{campaign_id}/queue`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-queue) Get a campaign's queue
- [`POST /v1/leads`](https://docs.versionseven.ai/api-reference/leads/create-lead) Create a lead
- [`PATCH /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/update-campaign) Update a campaign

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](https://docs.versionseven.ai/guides/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`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) lists campaigns; only `is_active` ones are checked.
2. [`GET /v1/campaigns/{campaign_id}/queue`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-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`](https://docs.versionseven.ai/api-reference/leads/create-lead), 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

```json
{
  "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

**Node.js**

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

**Python**

```python
# 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:

```bash
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](https://docs.versionseven.ai/cookbook/daily-campaign-digest#schedule-it) 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}`](https://docs.versionseven.ai/api-reference/campaigns/update-campaign), 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](https://docs.versionseven.ai/guides/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](https://docs.versionseven.ai/help/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

- [Post a daily campaign digest to Slack](https://docs.versionseven.ai/cookbook/daily-campaign-digest), which flags the runway this script acts on.
- [Add leads from Google Sheets to a campaign](https://docs.versionseven.ai/cookbook/add-leads-from-google-sheets), when the list lives in a sheet.
- [Lead database](https://docs.versionseven.ai/guides/lead-database) for the auto-refill alternative.
