# Keep campaigns in git and apply them

A tool that keeps a campaign as a JSON file in git. Plan shows what differs, apply writes sequence, AI fields, senders, Setter and webhook, export copies one.

Works with: GitHub Actions.

Endpoints used:

- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`POST /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/create-campaign) Create a campaign
- [`GET /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/retrieve-campaign) Retrieve a campaign
- [`PUT /v1/campaigns/{campaign_id}/sequence`](https://docs.versionseven.ai/api-reference/campaigns/replace-sequence) Replace a campaign's sequence
- [`GET /v1/campaigns/{campaign_id}/ai-fields`](https://docs.versionseven.ai/api-reference/campaigns/list-ai-fields) List AI fields
- [`POST /v1/campaigns/{campaign_id}/ai-fields`](https://docs.versionseven.ai/api-reference/campaigns/create-ai-field) Create an AI field
- [`PATCH /v1/campaigns/{campaign_id}/ai-fields/{field_id}`](https://docs.versionseven.ai/api-reference/campaigns/update-ai-field) Update an AI field
- [`PUT /v1/campaigns/{campaign_id}/senders`](https://docs.versionseven.ai/api-reference/campaigns/assign-senders) Assign sender accounts
- [`GET /v1/campaigns/{campaign_id}/responder`](https://docs.versionseven.ai/api-reference/campaigns/get-responder) Get the reply agent
- [`PATCH /v1/campaigns/{campaign_id}/responder`](https://docs.versionseven.ai/api-reference/campaigns/update-responder) Update the reply agent
- [`GET /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/list-webhooks) List a campaign's webhooks
- [`POST /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook) Create a webhook
- [`GET /v1/campaigns/{campaign_id}/preflight`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-preflight) Get the activation preflight
- [`PATCH /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/update-campaign) Update a campaign

A campaign is a sequence, a few AI fields, a sender list, a Setter configuration and a webhook. That's a document, and documents belong in version control: reviewed in a pull request, diffed when something changes, copied to a second workspace by changing one id. This recipe is a small tool with three commands. `plan` reads a campaign file and says what differs from the live campaign; `apply` makes it so, creating the campaign if it doesn't exist; `export` writes a live campaign to a file, which is also how you copy a winning campaign to another client's workspace.

## Before you start

- An API key with `campaigns:write` and `campaigns:read` in `VICTORIA_API_KEY`. See [Authentication](https://docs.versionseven.ai/guides/authentication).
- The connected sender accounts' ids from `GET /v1/accounts`, for the `senders` list.
- If the file declares a webhook, its signing secret in the environment variable the file names.
- Node.js 18 or later, or Python 3.10 or later with `requests`.

## The campaign file

One JSON file per campaign. The campaign is matched by `name`, so names must be unique in the workspace.

```json
{
  "name": "Q1 2027 outbound: heads of sales",
  "description": "Heads of Sales at US B2B SaaS, 50 to 300 people",
  "sequence": {
    "variation_a": [
      { "id": 1, "type": "email", "delay": 3, "subject": "a question about {company}", "content": "Hi {first_name},\n\nNoticed {recent_news}. Is ramping new reps on your list this quarter?\n\nJordan" },
      { "id": 2, "type": "email", "delay": 4, "subject": "re: a question about {company}", "content": "Hi {first_name},\n\nOne idea that's working for teams like yours: a 45-day ramp plan with weekly checkpoints. Happy to send it.\n\nJordan" }
    ]
  },
  "ai_fields": [
    {
      "field_name": "recent_news",
      "ai_instructions": "In one short sentence, name something specific the company announced or shipped recently, from its website.",
      "fallback_value": "the work your team is doing",
      "data_sources": ["website"]
    }
  ],
  "senders": ["a1b2c3d4-0000-4000-8000-000000000002"],
  "responder": {
    "is_enabled": true,
    "goal_link": "https://cal.com/jordan/intro",
    "company_name": "Ramply",
    "tone": "friendly",
    "custom_instructions": "Always offer the 45-day ramp plan before suggesting a call."
  },
  "webhook": { "url": "https://replies.example.com/hooks/q1-2027", "secret_env": "VICTORIA_WEBHOOK_SECRET" }
}
```

`sequence` is exactly what [`PUT /v1/campaigns/{campaign_id}/sequence`](https://docs.versionseven.ai/api-reference/campaigns/replace-sequence) takes; [Set up a campaign by API](https://docs.versionseven.ai/guides/set-up-a-campaign) describes the step types, and the Playbook's [example sequences](https://docs.versionseven.ai/playbook) can be pasted in. Everything after `sequence` is optional.

## The tool

**Node.js**

```javascript
// campaigns.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 [command, ...rest] = process.argv.slice(2);
const flags = new Set(rest.filter((arg) => arg.startsWith("--")));
const args = rest.filter((arg) => !arg.startsWith("--"));

async function call(method, path, body, headers = {}) {
  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(() => ({}));
  return { status: response.status, data };
}

async function must(method, path, body, headers) {
  const result = await call(method, path, body, headers);
  if (result.status >= 400) throw new Error(`${method} ${path} answered ${result.status} ${result.data.error ?? ""} ${result.data.message ?? ""}`);
  return result.data;
}

// The parts of a sequence that matter for a diff; the server may store more.
function projectSteps(steps) {
  return (steps ?? []).map((step) => {
    const out = {};
    for (const key of ["id", "type", "delay", "message", "subject", "content"]) if (step[key] !== undefined) out[key] = step[key];
    if (step.branches) out.branches = { yes: projectSteps(step.branches.yes), no: projectSteps(step.branches.no) };
    return out;
  });
}
const sameSequence = (a, b) =>
  JSON.stringify({ a: projectSteps(a?.variation_a), b: projectSteps(a?.variation_b) }) ===
  JSON.stringify({ a: projectSteps(b?.variation_a), b: projectSteps(b?.variation_b) });

async function findByName(name) {
  for (let offset = 0; ; ) {
    const data = await must("GET", `/campaigns?limit=500&offset=${offset}`);
    const found = data.campaigns.find((campaign) => campaign.name === name);
    if (found) return found;
    if (!data.has_more) return null;
    offset += data.count;
  }
}

async function apply(file, { dryRun }) {
  const desired = JSON.parse(await readFile(file, "utf8"));
  const changes = [];
  const write = async (label, fn) => {
    changes.push(label);
    if (!dryRun) await fn();
  };

  // 1. The campaign itself.
  let summary = await findByName(desired.name);
  let campaign;
  if (!summary) {
    await write(`create campaign "${desired.name}" with its sequence`, async () => {
      const key = createHash("sha256").update(`create:${desired.name}`).digest("hex");
      const created = await must("POST", "/campaigns", { name: desired.name, description: desired.description, sequence: desired.sequence }, { "Idempotency-Key": key });
      summary = { id: created.campaign_id };
    });
    if (dryRun) {
      changes.push("…then add AI fields, senders, the Setter and the webhook to the new campaign");
      return changes;
    }
    campaign = (await must("GET", `/campaigns/${summary.id}`)).campaign;
  } else {
    campaign = (await must("GET", `/campaigns/${summary.id}`)).campaign;
    if ((desired.description ?? null) !== (campaign.description ?? null)) {
      await write("update description", () => must("PATCH", `/campaigns/${campaign.id}`, { description: desired.description }));
    }
    if (!sameSequence(desired.sequence, campaign.sequence)) {
      await write("replace sequence", async () => {
        const result = await call("PUT", `/campaigns/${campaign.id}/sequence`, { sequence: desired.sequence, expected_updated_at: campaign.updated_at });
        if (result.data.error === "SEQUENCE_MODIFIED") throw new Error("The campaign changed since it was read (someone edited it in the app). Run plan again, then apply.");
        if (result.status >= 400) throw new Error(`PUT sequence answered ${result.status} ${result.data.error}: ${result.data.message ?? ""}`);
      });
    }
  }
  const id = campaign.id;

  // 2. AI fields: declared ones are created or updated; undeclared active ones are switched off.
  const live = (await must("GET", `/campaigns/${id}/ai-fields`)).ai_fields;
  const byName = new Map(live.map((field) => [field.field_name.toLowerCase(), field]));
  for (const field of desired.ai_fields ?? []) {
    const existing = byName.get(field.field_name.toLowerCase());
    if (!existing) {
      await write(`add AI field {${field.field_name}}`, () => must("POST", `/campaigns/${id}/ai-fields`, field));
    } else {
      const patch = {};
      for (const key of ["ai_instructions", "fallback_value", "field_description", "data_sources"]) {
        if (field[key] !== undefined && JSON.stringify(field[key]) !== JSON.stringify(existing[key])) patch[key] = field[key];
      }
      if (!existing.is_active) patch.is_active = true;
      if (Object.keys(patch).length) await write(`update AI field {${field.field_name}} (${Object.keys(patch).join(", ")})`, () => must("PATCH", `/campaigns/${id}/ai-fields/${existing.id}`, patch));
    }
  }
  const declared = new Set((desired.ai_fields ?? []).map((field) => field.field_name.toLowerCase()));
  for (const field of live) {
    if (field.is_active && !declared.has(field.field_name.toLowerCase())) {
      await write(`switch off AI field {${field.field_name}} (not in the file)`, () => must("PATCH", `/campaigns/${id}/ai-fields/${field.id}`, { is_active: false }));
    }
  }

  // 3. Senders.
  if (desired.senders) {
    const current = new Set((campaign.enabled_accounts ?? []).map((account) => (typeof account === "string" ? account : account.id)));
    const wanted = new Set(desired.senders);
    if (current.size !== wanted.size || [...wanted].some((account) => !current.has(account))) {
      await write(`set senders (${desired.senders.length})`, () => must("PUT", `/campaigns/${id}/senders`, { account_ids: desired.senders }));
    }
  }

  // 4. The Appointment Setter.
  if (desired.responder) {
    const current = (await must("GET", `/campaigns/${id}/responder`)).responder ?? {};
    const patch = {};
    for (const [key, value] of Object.entries(desired.responder)) if (JSON.stringify(value) !== JSON.stringify(current[key])) patch[key] = value;
    if (Object.keys(patch).length) await write(`update the Setter (${Object.keys(patch).join(", ")})`, () => must("PATCH", `/campaigns/${id}/responder`, patch));
  }

  // 5. The webhook: one per campaign, repointed when the file says somewhere else.
  if (desired.webhook) {
    const hooks = (await must("GET", `/campaigns/${id}/webhooks`)).webhooks;
    if (!hooks.some((hook) => hook.webhook_url === desired.webhook.url && hook.is_enabled)) {
      const secret = process.env[desired.webhook.secret_env];
      if (!secret) throw new Error(`Set ${desired.webhook.secret_env} for the webhook secret`);
      await write(`point the webhook at ${desired.webhook.url}`, () => must("POST", `/campaigns/${id}/webhooks`, { webhook_url: desired.webhook.url, secret, replace: true }));
    }
  }

  // 6. Preflight, and activation only when asked.
  if (!dryRun) {
    const preflight = await must("GET", `/campaigns/${id}/preflight`);
    for (const check of preflight.checks.filter((check) => check.status !== "pass")) console.log(`  ${check.status.toUpperCase()} ${check.label}: ${check.detail ?? ""}`);
    console.log(`preflight: ${preflight.ok ? "nothing blocks" : `${preflight.blocking_count} blocking`}, ${preflight.warning_count} warning(s)`);
    if (flags.has("--activate")) {
      let result = await call("PATCH", `/campaigns/${id}`, { is_active: true });
      if (result.data.error === "CAMPAIGN_ACTIVATION_WARNINGS" && flags.has("--ack")) result = await call("PATCH", `/campaigns/${id}`, { is_active: true, ack_warnings: true });
      if (result.status === 200) changes.push("activated");
      else console.log(`not activated: ${result.data.error} (${result.data.message ?? ""})${result.data.error === "CAMPAIGN_ACTIVATION_WARNINGS" ? "; add --ack to accept the warnings" : ""}`);
    }
  }
  return changes;
}

async function exportCampaign(id, file) {
  const campaign = (await must("GET", `/campaigns/${id}`)).campaign;
  const fields = (await must("GET", `/campaigns/${id}/ai-fields`)).ai_fields.filter((field) => field.is_active);
  const responder = (await must("GET", `/campaigns/${id}/responder`)).responder;
  const hooks = (await must("GET", `/campaigns/${id}/webhooks`)).webhooks.filter((hook) => hook.is_enabled);
  const out = {
    name: campaign.name,
    description: campaign.description ?? undefined,
    sequence: { variation_a: projectSteps(campaign.sequence?.variation_a), ...(campaign.sequence?.variation_b?.length ? { variation_b: projectSteps(campaign.sequence.variation_b) } : {}) },
    ai_fields: fields.map(({ field_name, ai_instructions, fallback_value, field_description, data_sources }) => ({ field_name, ai_instructions, fallback_value, ...(field_description ? { field_description } : {}), ...(data_sources ? { data_sources } : {}) })),
    senders: (campaign.enabled_accounts ?? []).map((account) => (typeof account === "string" ? account : account.id)),
    ...(responder ? { responder: Object.fromEntries(Object.entries(responder).filter(([key]) => ["is_enabled", "goal_link", "company_name", "agent_name", "tone", "campaign_description", "custom_instructions", "assets"].includes(key))) } : {}),
    ...(hooks[0] ? { webhook: { url: hooks[0].webhook_url, secret_env: "VICTORIA_WEBHOOK_SECRET" } } : {}),
  };
  await writeFile(file, `${JSON.stringify(out, null, 2)}\n`);
  console.log(`wrote ${file}. Senders are this workspace's account ids; replace them before applying elsewhere.`);
}

if (command === "plan") {
  const changes = await apply(args[0], { dryRun: true });
  console.log(changes.length ? `plan:\n  - ${changes.join("\n  - ")}` : "plan: no changes");
} else if (command === "apply") {
  const changes = await apply(args[0], { dryRun: false });
  console.log(changes.length ? `applied:\n  - ${changes.join("\n  - ")}` : "applied: no changes");
} else if (command === "export") {
  await exportCampaign(args[0], args[1] ?? "campaign.json");
} else {
  console.error("Usage: node campaigns.mjs plan <file> | apply <file> [--activate [--ack]] | export <campaign_id> [file]");
  process.exit(2);
}
```

**Python**

```python
# campaigns.py
import hashlib
import json
import os
import sys

import requests

API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
COMMAND = sys.argv[1] if len(sys.argv) > 1 else None
FLAGS = {arg for arg in sys.argv[2:] if arg.startswith("--")}
ARGS = [arg for arg in sys.argv[2:] if not arg.startswith("--")]


def call(method, path, body=None, headers=None):
    response = requests.request(method, f"{API_URL}{path}", headers={**HEADERS, **(headers or {})}, json=body, timeout=30)
    try:
        data = response.json()
    except ValueError:
        data = {}
    return response.status_code, data


def must(method, path, body=None, headers=None):
    status, data = call(method, path, body, headers)
    if status >= 400:
        raise SystemExit(f"{method} {path} answered {status} {data.get('error', '')} {data.get('message', '')}")
    return data


def project_steps(steps):
    """The parts of a sequence that matter for a diff; the server may store more."""
    out = []
    for step in steps or []:
        item = {key: step[key] for key in ("id", "type", "delay", "message", "subject", "content") if key in step}
        if step.get("branches"):
            item["branches"] = {"yes": project_steps(step["branches"].get("yes")), "no": project_steps(step["branches"].get("no"))}
        out.append(item)
    return out


def same_sequence(a, b):
    a, b = a or {}, b or {}
    return (project_steps(a.get("variation_a")), project_steps(a.get("variation_b"))) == (
        project_steps(b.get("variation_a")),
        project_steps(b.get("variation_b")),
    )


def find_by_name(name):
    offset = 0
    while True:
        data = must("GET", f"/campaigns?limit=500&offset={offset}")
        for campaign in data["campaigns"]:
            if campaign["name"] == name:
                return campaign
        if not data["has_more"]:
            return None
        offset += data["count"]


def apply(file, dry_run):
    with open(file, encoding="utf-8") as handle:
        desired = json.load(handle)
    changes = []

    def write(label, fn):
        changes.append(label)
        if not dry_run:
            fn()

    # 1. The campaign itself.
    summary = find_by_name(desired["name"])
    if not summary:
        def create():
            key = hashlib.sha256(f"create:{desired['name']}".encode()).hexdigest()
            created = must("POST", "/campaigns", {"name": desired["name"], "description": desired.get("description"), "sequence": desired["sequence"]}, {"Idempotency-Key": key})
            summary_holder["id"] = created["campaign_id"]

        summary_holder = {}
        write(f'create campaign "{desired["name"]}" with its sequence', create)
        if dry_run:
            changes.append("…then add AI fields, senders, the Setter and the webhook to the new campaign")
            return changes
        campaign = must("GET", f"/campaigns/{summary_holder['id']}")["campaign"]
    else:
        campaign = must("GET", f"/campaigns/{summary['id']}")["campaign"]
        if desired.get("description") != campaign.get("description"):
            write("update description", lambda: must("PATCH", f"/campaigns/{campaign['id']}", {"description": desired.get("description")}))
        if not same_sequence(desired["sequence"], campaign.get("sequence")):
            def replace_sequence():
                status, data = call("PUT", f"/campaigns/{campaign['id']}/sequence", {"sequence": desired["sequence"], "expected_updated_at": campaign["updated_at"]})
                if data.get("error") == "SEQUENCE_MODIFIED":
                    raise SystemExit("The campaign changed since it was read (someone edited it in the app). Run plan again, then apply.")
                if status >= 400:
                    raise SystemExit(f"PUT sequence answered {status} {data.get('error')}: {data.get('message', '')}")

            write("replace sequence", replace_sequence)
    cid = campaign["id"]

    # 2. AI fields: declared ones are created or updated; undeclared active ones are switched off.
    live = must("GET", f"/campaigns/{cid}/ai-fields")["ai_fields"]
    by_name = {field["field_name"].lower(): field for field in live}
    for field in desired.get("ai_fields", []):
        existing = by_name.get(field["field_name"].lower())
        if not existing:
            write(f"add AI field {{{field['field_name']}}}", lambda f=field: must("POST", f"/campaigns/{cid}/ai-fields", f))
            continue
        patch = {key: field[key] for key in ("ai_instructions", "fallback_value", "field_description", "data_sources") if key in field and field[key] != existing.get(key)}
        if not existing.get("is_active"):
            patch["is_active"] = True
        if patch:
            write(f"update AI field {{{field['field_name']}}} ({', '.join(patch)})", lambda e=existing, p=patch: must("PATCH", f"/campaigns/{cid}/ai-fields/{e['id']}", p))
    declared = {field["field_name"].lower() for field in desired.get("ai_fields", [])}
    for field in live:
        if field.get("is_active") and field["field_name"].lower() not in declared:
            write(f"switch off AI field {{{field['field_name']}}} (not in the file)", lambda f=field: must("PATCH", f"/campaigns/{cid}/ai-fields/{f['id']}", {"is_active": False}))

    # 3. Senders.
    if "senders" in desired:
        current = {account if isinstance(account, str) else account.get("id") for account in campaign.get("enabled_accounts") or []}
        if current != set(desired["senders"]):
            write(f"set senders ({len(desired['senders'])})", lambda: must("PUT", f"/campaigns/{cid}/senders", {"account_ids": desired["senders"]}))

    # 4. The Appointment Setter.
    if "responder" in desired:
        current = must("GET", f"/campaigns/{cid}/responder").get("responder") or {}
        patch = {key: value for key, value in desired["responder"].items() if value != current.get(key)}
        if patch:
            write(f"update the Setter ({', '.join(patch)})", lambda p=patch: must("PATCH", f"/campaigns/{cid}/responder", p))

    # 5. The webhook: one per campaign, repointed when the file says somewhere else.
    if "webhook" in desired:
        hooks = must("GET", f"/campaigns/{cid}/webhooks")["webhooks"]
        if not any(hook["webhook_url"] == desired["webhook"]["url"] and hook.get("is_enabled") for hook in hooks):
            secret = os.environ.get(desired["webhook"]["secret_env"])
            if not secret:
                raise SystemExit(f"Set {desired['webhook']['secret_env']} for the webhook secret")
            write(f"point the webhook at {desired['webhook']['url']}", lambda: must("POST", f"/campaigns/{cid}/webhooks", {"webhook_url": desired["webhook"]["url"], "secret": secret, "replace": True}))

    # 6. Preflight, and activation only when asked.
    if not dry_run:
        preflight = must("GET", f"/campaigns/{cid}/preflight")
        for check in preflight["checks"]:
            if check["status"] != "pass":
                print(f"  {check['status'].upper()} {check['label']}: {check.get('detail') or ''}")
        verdict = "nothing blocks" if preflight["ok"] else f"{preflight['blocking_count']} blocking"
        print(f"preflight: {verdict}, {preflight['warning_count']} warning(s)")
        if "--activate" in FLAGS:
            status, data = call("PATCH", f"/campaigns/{cid}", {"is_active": True})
            if data.get("error") == "CAMPAIGN_ACTIVATION_WARNINGS" and "--ack" in FLAGS:
                status, data = call("PATCH", f"/campaigns/{cid}", {"is_active": True, "ack_warnings": True})
            if status == 200:
                changes.append("activated")
            else:
                hint = "; add --ack to accept the warnings" if data.get("error") == "CAMPAIGN_ACTIVATION_WARNINGS" else ""
                print(f"not activated: {data.get('error')} ({data.get('message', '')}){hint}")
    return changes


def export_campaign(cid, file):
    campaign = must("GET", f"/campaigns/{cid}")["campaign"]
    fields = [f for f in must("GET", f"/campaigns/{cid}/ai-fields")["ai_fields"] if f.get("is_active")]
    responder = must("GET", f"/campaigns/{cid}/responder").get("responder")
    hooks = [h for h in must("GET", f"/campaigns/{cid}/webhooks")["webhooks"] if h.get("is_enabled")]
    sequence = campaign.get("sequence") or {}
    out = {
        "name": campaign["name"],
        "sequence": {"variation_a": project_steps(sequence.get("variation_a"))},
        "ai_fields": [
            {key: f[key] for key in ("field_name", "ai_instructions", "fallback_value", "field_description", "data_sources") if f.get(key) is not None}
            for f in fields
        ],
        "senders": [a if isinstance(a, str) else a.get("id") for a in campaign.get("enabled_accounts") or []],
    }
    if campaign.get("description"):
        out["description"] = campaign["description"]
    if sequence.get("variation_b"):
        out["sequence"]["variation_b"] = project_steps(sequence["variation_b"])
    if responder:
        keys = ("is_enabled", "goal_link", "company_name", "agent_name", "tone", "campaign_description", "custom_instructions", "assets")
        out["responder"] = {key: responder[key] for key in keys if key in responder}
    if hooks:
        out["webhook"] = {"url": hooks[0]["webhook_url"], "secret_env": "VICTORIA_WEBHOOK_SECRET"}
    with open(file, "w", encoding="utf-8") as handle:
        json.dump(out, handle, indent=2)
        handle.write("\n")
    print(f"wrote {file}. Senders are this workspace's account ids; replace them before applying elsewhere.")


if COMMAND == "plan":
    changes = apply(ARGS[0], dry_run=True)
    print("plan:\n  - " + "\n  - ".join(changes) if changes else "plan: no changes")
elif COMMAND == "apply":
    changes = apply(ARGS[0], dry_run=False)
    print("applied:\n  - " + "\n  - ".join(changes) if changes else "applied: no changes")
elif COMMAND == "export":
    export_campaign(ARGS[0], ARGS[1] if len(ARGS) > 1 else "campaign.json")
else:
    raise SystemExit("Usage: python campaigns.py plan <file> | apply <file> [--activate [--ack]] | export <campaign_id> [file]")
```

```bash
node campaigns.mjs plan campaigns/q1-2027.json          # what would change; writes nothing
node campaigns.mjs apply campaigns/q1-2027.json         # make it so, then show the preflight
node campaigns.mjs apply campaigns/q1-2027.json --activate --ack
node campaigns.mjs export 550e8400-e29b-41d4-a716-446655440000 campaigns/q1-2027.json
```

## How apply decides

| Part | Read | Written when |
| - | - | - |
| Campaign | [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) by name, then [`GET /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/retrieve-campaign) | Missing: [`POST /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/create-campaign) with the sequence, under an `Idempotency-Key` from the name. Present: `PATCH` when the description differs. |
| Sequence | the campaign's `sequence` and `updated_at` | The steps differ: [`PUT /v1/campaigns/{campaign_id}/sequence`](https://docs.versionseven.ai/api-reference/campaigns/replace-sequence) with `expected_updated_at`. If anyone changed the campaign in the app since the read, the server answers `409 SEQUENCE_MODIFIED` and nothing is written; `plan` again and `apply`. |
| AI fields | [`GET /v1/campaigns/{campaign_id}/ai-fields`](https://docs.versionseven.ai/api-reference/campaigns/list-ai-fields) | A declared field is created (`POST`) or patched when its instructions, fallback or sources differ; an active field not in the file is switched off with `is_active: false`. The file is the declaration. |
| Senders | the campaign's `enabled_accounts` | The set differs: [`PUT /v1/campaigns/{campaign_id}/senders`](https://docs.versionseven.ai/api-reference/campaigns/assign-senders), which replaces the set. |
| Setter | [`GET /v1/campaigns/{campaign_id}/responder`](https://docs.versionseven.ai/api-reference/campaigns/get-responder) | Any declared key differs: [`PATCH /v1/campaigns/{campaign_id}/responder`](https://docs.versionseven.ai/api-reference/campaigns/update-responder) with just those keys. |
| Webhook | [`GET /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/list-webhooks) | No enabled webhook at the file's URL: [`POST /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook) with `replace: true`, so the campaign's one webhook moves. The secret comes from the environment variable the file names, never from the file. |
| Activation | [`GET /v1/campaigns/{campaign_id}/preflight`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-preflight) after every apply | Only with `--activate`: [`PATCH /v1/campaigns/{campaign_id}`](https://docs.versionseven.ai/api-reference/campaigns/update-campaign) and `is_active: true`. Warnings answer `409 CAMPAIGN_ACTIVATION_WARNINGS` and stop it; `--ack` repeats with `ack_warnings: true`. A blocking check (`409 CAMPAIGN_NOT_READY`) can't be overridden. |

The tool never pauses a campaign, never deletes one, and never removes leads. Those are decisions, not declarations.

## In a pull request

A GitHub Actions workflow that runs `plan` on every pull request and `apply` on merge gives campaign changes a review step:

```yaml
# .github/workflows/campaigns.yml
name: Campaigns
on:
  pull_request:
    paths: ["campaigns/**"]
  push:
    branches: [main]
    paths: ["campaigns/**"]
jobs:
  campaigns:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Plan or apply every changed campaign
        run: |
          cmd=$([ "${{ github.event_name }}" = "push" ] && echo apply || echo plan)
          for file in campaigns/*.json; do echo "== $file"; node campaigns.mjs "$cmd" "$file"; done
        env:
          VICTORIA_API_KEY: ${{ secrets.VICTORIA_API_KEY }}
          VICTORIA_WEBHOOK_SECRET: ${{ secrets.VICTORIA_WEBHOOK_SECRET }}
```

Activation stays a human step: run `apply --activate` from a terminal after reading the preflight, or add a `workflow_dispatch` job with an input for it.

## Copying a campaign to another workspace

`export` writes a live campaign to a file. The sequence, AI fields and Setter travel as they are; `senders` are account ids that exist only in the source workspace, so replace them with the destination's ids (from its `GET /v1/accounts`) and change the webhook URL if the destination has its own receiver. Then `apply` with the destination's API key. For an agency, that's a template campaign kept in the repo and applied per client.

## What to expect

- The server validates the sequence on every write and answers `400 SEQUENCE_VALIDATION_FAILED` with the rule that failed, so a broken file can't half-apply the sequence; the campaign keeps its previous one.
- A campaign that's active keeps sending while its sequence is replaced. Leads mid-sequence continue on the new steps by position; [Edit a live campaign](https://docs.versionseven.ai/help/edit-a-live-campaign) explains the rules the app follows, which are the same here.
- `plan` on a campaign that doesn't exist yet can only say it would create it: the AI fields, senders, Setter and webhook can't be compared against nothing, so it lists them as the next step.
- Names are the key. Renaming a campaign in the file creates a new one; rename it in the app first, or export and re-apply.

## Next steps

- [Set up a campaign by API](https://docs.versionseven.ai/guides/set-up-a-campaign), the steps this tool automates.
- [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver), for the webhook URL the file declares.
- [Run agency operations across client workspaces](https://docs.versionseven.ai/cookbook/agency-operations), which applies one template to many workspaces.
