Cookbook
View as Markdown

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

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:read in VICTORIA_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:

CallUsed 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}/queuedays_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=7dA 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=7An 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);
}

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 runs

Schedule 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>&1

Or 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 warnings say 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_runway is 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_value went out instead of personalised text on that share of leads. Rewrite its ai_instructions or data_sources with PATCH /v1/campaigns/{campaign_id}/ai-fields/{field_id}, then watch the next digests. Personalization fields explains held leads.

What to expect

  • daily-stats for yesterday is read live, so the row is complete; the same call for today carries partial: true and would undercount.
  • days_of_runway is null when the campaign has no capacity or no active sender, which is different from 0 (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