Post a daily campaign digest to Slack
A scheduled script that posts one Slack message a day with each active campaign's numbers, its lead runway, idle senders and AI fields that keep falling back.
Last updated
- Slack
- GitHub Actions
- cron
Endpoints used
The app's dashboard answers "how is it going?" when you open it. This recipe answers it before you do: every morning, one Slack message with yesterday's sends, replies and meetings for each active campaign, plus three things worth a look that the numbers alone don't show: a campaign about to run out of leads, a sender that sent nothing all week, and an AI field that keeps falling back to its default text.
Before you start
- An API key with
campaigns:readinVICTORIA_API_KEY. Nothing here writes. See Authentication. - A Slack incoming webhook URL in
SLACK_WEBHOOK_URL. Without it, the digest prints to the console. - Node.js 18 or later, or Python 3.10 or later with
requests.
What it reads
For each campaign from GET /v1/campaigns with is_active: true:
| Call | Used for |
|---|---|
GET /v1/campaigns/{campaign_id}/daily-stats with from and to set to yesterday (UTC) | Yesterday's line: leads first contacted, first replied, positive replies, meetings booked. |
GET /v1/campaigns/{campaign_id}/queue | days_of_runway: backlog divided by daily capacity. Under 7 days is flagged; null means the campaign can't send at all (no active sender or no capacity), which is flagged harder. |
GET /v1/campaigns/{campaign_id}/senders/breakdown with date_filter=7d | A sender that is active on the campaign but sent nothing in 7 days: a disconnected account, an empty ramp, or a sender assigned by mistake. |
GET /v1/campaigns/{campaign_id}/personalization-quality with days=7 | An AI field whose fallback and held runs are more than a quarter of its runs: its instructions aren't finding data for these leads. |
Everything is per lead unless the metric says messages. Analytics metrics defines each number the same way the app does.
The script
// digest.mjs
const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
const RUNWAY_FLAG_DAYS = 7;
const FALLBACK_FLAG_PCT = 25;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Sends a request, waiting and retrying on 429 and 503.
async function call(path) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(`${API_URL}${path}`, { headers: { Authorization: `Bearer ${API_KEY}` } });
const data = await response.json().catch(() => ({}));
if ((response.status !== 429 && response.status !== 503) || attempt === 5) {
if (response.status !== 200) throw new Error(`${path} answered ${response.status} ${data.error ?? ""}`);
return data;
}
await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
}
}
async function listCampaigns() {
const campaigns = [];
for (let offset = 0; ; ) {
const data = await call(`/campaigns?limit=500&offset=${offset}`);
campaigns.push(...data.campaigns);
if (!data.has_more) return campaigns;
offset += data.count;
}
}
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const pct = (part, whole) => (whole > 0 ? Math.round((part / whole) * 100) : 0);
async function campaignReport(campaign) {
const [stats, queue, senders, quality] = await Promise.all([
call(`/campaigns/${campaign.id}/daily-stats?from=${yesterday}&to=${yesterday}`),
call(`/campaigns/${campaign.id}/queue`),
call(`/campaigns/${campaign.id}/senders/breakdown?date_filter=7d`),
call(`/campaigns/${campaign.id}/personalization-quality?days=7`),
]);
const day = stats.days[0] ?? {};
const lines = [
`*${campaign.name}*: ${day.leads_first_contacted ?? 0} contacted, ${day.leads_first_replied ?? 0} replied` +
` (${day.replies_positive ?? 0} positive), ${day.meetings_booked ?? 0} meetings booked yesterday`,
];
const flags = [];
if (queue.days_of_runway === null) {
flags.push(`no runway: ${queue.warnings.join("; ") || "no capacity or no active senders"}`);
} else if (queue.days_of_runway < RUNWAY_FLAG_DAYS) {
flags.push(`runway ${queue.days_of_runway} days (${queue.backlog} leads left at ${queue.daily_capacity} a day)`);
}
for (const sender of senders.senders) {
if (sender.is_active && (sender.messages_sent ?? 0) === 0 && (sender.requests ?? 0) === 0) {
flags.push(`sender ${sender.label} (${sender.platform}) sent nothing in 7 days`);
}
}
for (const field of quality.fields) {
const missed = (field.fallback ?? 0) + (field.held ?? 0);
if ((field.runs ?? 0) >= 10 && pct(missed, field.runs) > FALLBACK_FLAG_PCT) {
flags.push(`AI field {${field.field_name}} fell back or held on ${pct(missed, field.runs)}% of ${field.runs} runs`);
}
}
lines.push(...flags.map((flag) => ` :warning: ${flag}`));
return lines.join("\n");
}
const campaigns = (await listCampaigns()).filter((campaign) => campaign.is_active);
const reports = [];
for (const campaign of campaigns) {
reports.push(await campaignReport(campaign));
await sleep(650); // four calls per campaign, under 100 a minute to any one endpoint
}
const text = campaigns.length
? [`:sunrise: Outbound digest for ${yesterday}`, ...reports].join("\n\n")
: `:sunrise: Outbound digest for ${yesterday}: no active campaigns`;
if (SLACK_WEBHOOK_URL) {
const response = await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Slack answered ${response.status}`);
} else {
console.log(text);
}# digest.py
import os
import time
from datetime import datetime, timedelta, timezone
import requests
API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
RUNWAY_FLAG_DAYS = 7
FALLBACK_FLAG_PCT = 25
def call(path):
"""Sends a request, waiting and retrying on 429 and 503."""
for attempt in range(1, 6):
response = requests.get(f"{API_URL}{path}", headers=HEADERS, timeout=30)
try:
data = response.json()
except ValueError:
data = {}
if response.status_code not in (429, 503) or attempt == 5:
if response.status_code != 200:
raise SystemExit(f"{path} answered {response.status_code} {data.get('error', '')}")
return data
time.sleep(float(response.headers.get("Retry-After") or 2**attempt))
def list_campaigns():
campaigns, offset = [], 0
while True:
data = call(f"/campaigns?limit=500&offset={offset}")
campaigns.extend(data["campaigns"])
if not data["has_more"]:
return campaigns
offset += data["count"]
def pct(part, whole):
return round(part / whole * 100) if whole else 0
YESTERDAY = (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat()
def campaign_report(campaign):
stats = call(f"/campaigns/{campaign['id']}/daily-stats?from={YESTERDAY}&to={YESTERDAY}")
queue = call(f"/campaigns/{campaign['id']}/queue")
senders = call(f"/campaigns/{campaign['id']}/senders/breakdown?date_filter=7d")
quality = call(f"/campaigns/{campaign['id']}/personalization-quality?days=7")
day = stats["days"][0] if stats["days"] else {}
lines = [
f"*{campaign['name']}*: {day.get('leads_first_contacted') or 0} contacted, "
f"{day.get('leads_first_replied') or 0} replied ({day.get('replies_positive') or 0} positive), "
f"{day.get('meetings_booked') or 0} meetings booked yesterday"
]
flags = []
if queue["days_of_runway"] is None:
flags.append("no runway: " + ("; ".join(queue["warnings"]) or "no capacity or no active senders"))
elif queue["days_of_runway"] < RUNWAY_FLAG_DAYS:
flags.append(f"runway {queue['days_of_runway']} days ({queue['backlog']} leads left at {queue['daily_capacity']} a day)")
for sender in senders["senders"]:
if sender.get("is_active") and not (sender.get("messages_sent") or 0) and not (sender.get("requests") or 0):
flags.append(f"sender {sender.get('label')} ({sender.get('platform')}) sent nothing in 7 days")
for field in quality["fields"]:
missed = (field.get("fallback") or 0) + (field.get("held") or 0)
runs = field.get("runs") or 0
if runs >= 10 and pct(missed, runs) > FALLBACK_FLAG_PCT:
flags.append(f"AI field {{{field['field_name']}}} fell back or held on {pct(missed, runs)}% of {runs} runs")
lines.extend(f" :warning: {flag}" for flag in flags)
return "\n".join(lines)
def main():
campaigns = [c for c in list_campaigns() if c.get("is_active")]
reports = []
for campaign in campaigns:
reports.append(campaign_report(campaign))
time.sleep(0.65) # four calls per campaign, under 100 a minute to any one endpoint
if campaigns:
text = "\n\n".join([f":sunrise: Outbound digest for {YESTERDAY}", *reports])
else:
text = f":sunrise: Outbound digest for {YESTERDAY}: no active campaigns"
if SLACK_WEBHOOK_URL:
response = requests.post(SLACK_WEBHOOK_URL, json={"text": text}, timeout=10)
response.raise_for_status()
else:
print(text)
if __name__ == "__main__":
main()Run it once by hand, without SLACK_WEBHOOK_URL, to see the message in the console:
:sunrise: Outbound digest for 2026-10-05
*Q3 outbound: heads of sales*: 25 contacted, 3 replied (1 positive), 0 meetings booked yesterday
:warning: runway 3 days (60 leads left at 20 a day)
:warning: sender jane-doe (linkedin) sent nothing in 7 days
:warning: AI field {recent_news} fell back or held on 35% of 40 runsSchedule it
A crontab line for 07:00 in the server's timezone:
0 7 * * * cd /opt/victoria-digest && VICTORIA_API_KEY=vk_… SLACK_WEBHOOK_URL=https://hooks.slack.com/… node digest.mjs >> digest.log 2>&1Or a GitHub Actions workflow; cron there is UTC:
# .github/workflows/digest.yml
name: Outbound digest
on:
schedule:
- cron: "0 7 * * 1-5"
workflow_dispatch:
jobs:
digest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node digest.mjs
env:
VICTORIA_API_KEY: ${{ secrets.VICTORIA_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}What each flag means, and what to do
- No runway: the queue's
warningssay why: no enabled sender, every sender disconnected, or no daily limits. Alert on disconnected senders handles the disconnected case on its own. - Runway under 7 days: add leads, or let Keep campaigns fed do it.
days_of_runwayis the backlog divided by the campaign's first-touch capacity a day, so it assumes every sender keeps sending. - A sender sent nothing in 7 days: open Sender Accounts in the app. A LinkedIn account in its first days sends very little by design (the warm-up ramp), so a brand-new sender can trip this flag once.
- An AI field falling back: the field's
fallback_valuewent out instead of personalised text on that share of leads. Rewrite itsai_instructionsordata_sourceswithPATCH /v1/campaigns/{campaign_id}/ai-fields/{field_id}, then watch the next digests. Personalization fields explains held leads.
What to expect
daily-statsfor yesterday is read live, so the row is complete; the same call for today carriespartial: trueand would undercount.days_of_runwayisnullwhen the campaign has no capacity or no active sender, which is different from0(capacity, but an empty backlog).- The 7-day sender window is per lead for replies and per message for sends, as everywhere in the analytics.
- Four calls per campaign: with twenty active campaigns the script makes about eighty requests, under the limit of 100 a minute to any one endpoint, and the pause between campaigns keeps it there.
Next steps
- Alert on disconnected senders with a reconnect link, to fix the most common "no runway" cause automatically.
- Keep campaigns fed, to act on the runway flag instead of reading it.
- Analytics metrics for what each number means.