Cookbook
View as Markdown

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.

Last updated

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

{
  "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 takes; Set up a campaign by API describes the step types, and the Playbook's example sequences can be pasted in. Everything after sequence is optional.

The tool

// 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);
}
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

PartReadWritten when
CampaignGET /v1/campaigns by name, then GET /v1/campaigns/{campaign_id}Missing: POST /v1/campaigns with the sequence, under an Idempotency-Key from the name. Present: PATCH when the description differs.
Sequencethe campaign's sequence and updated_atThe steps differ: PUT /v1/campaigns/{campaign_id}/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 fieldsGET /v1/campaigns/{campaign_id}/ai-fieldsA 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.
Sendersthe campaign's enabled_accountsThe set differs: PUT /v1/campaigns/{campaign_id}/senders, which replaces the set.
SetterGET /v1/campaigns/{campaign_id}/responderAny declared key differs: PATCH /v1/campaigns/{campaign_id}/responder with just those keys.
WebhookGET /v1/campaigns/{campaign_id}/webhooksNo enabled webhook at the file's URL: POST /v1/campaigns/{campaign_id}/webhooks with replace: true, so the campaign's one webhook moves. The secret comes from the environment variable the file names, never from the file.
ActivationGET /v1/campaigns/{campaign_id}/preflight after every applyOnly with --activate: PATCH /v1/campaigns/{campaign_id} 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:

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