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
- GitHub Actions
Endpoints used
- GET
/v1/campaignsList campaigns - POST
/v1/campaignsCreate a campaign - GET
/v1/campaigns/{campaign_id}Retrieve a campaign - PUT
/v1/campaigns/{campaign_id}/sequenceReplace a campaign's sequence - GET
/v1/campaigns/{campaign_id}/ai-fieldsList AI fields - POST
/v1/campaigns/{campaign_id}/ai-fieldsCreate an AI field - PATCH
/v1/campaigns/{campaign_id}/ai-fields/{field_id}Update an AI field - PUT
/v1/campaigns/{campaign_id}/sendersAssign sender accounts - GET
/v1/campaigns/{campaign_id}/responderGet the reply agent - PATCH
/v1/campaigns/{campaign_id}/responderUpdate the reply agent - GET
/v1/campaigns/{campaign_id}/webhooksList a campaign's webhooks - POST
/v1/campaigns/{campaign_id}/webhooksCreate a webhook - GET
/v1/campaigns/{campaign_id}/preflightGet the activation preflight - PATCH
/v1/campaigns/{campaign_id}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:writeandcampaigns:readinVICTORIA_API_KEY. See Authentication. - The connected sender accounts' ids from
GET /v1/accounts, for thesenderslist. - 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);
}# 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]")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.jsonHow apply decides
| Part | Read | Written when |
|---|---|---|
| Campaign | GET /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. |
| Sequence | the campaign's sequence and updated_at | The 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 fields | GET /v1/campaigns/{campaign_id}/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, which replaces the set. |
| Setter | GET /v1/campaigns/{campaign_id}/responder | Any declared key differs: PATCH /v1/campaigns/{campaign_id}/responder with just those keys. |
| Webhook | GET /v1/campaigns/{campaign_id}/webhooks | No 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. |
| Activation | GET /v1/campaigns/{campaign_id}/preflight after every apply | Only 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_FAILEDwith 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.
planon 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, the steps this tool automates.
- Receive replies once and fan them out, for the webhook URL the file declares.
- Run agency operations across client workspaces, which applies one template to many workspaces.