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

Works with: Slack, GitHub Actions, cron.

Endpoints used:

- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`GET /v1/campaigns/{campaign_id}/daily-stats`](https://docs.versionseven.ai/api-reference/campaigns/get-daily-stats) Get daily stats
- [`GET /v1/campaigns/{campaign_id}/queue`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-queue) Get a campaign's queue
- [`GET /v1/campaigns/{campaign_id}/senders/breakdown`](https://docs.versionseven.ai/api-reference/campaigns/get-sender-breakdown) Get the sender breakdown
- [`GET /v1/campaigns/{campaign_id}/personalization-quality`](https://docs.versionseven.ai/api-reference/campaigns/get-personalization-quality) Get personalization quality

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](https://docs.versionseven.ai/guides/authentication).
- A Slack [incoming webhook](https://api.slack.com/messaging/webhooks) 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`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) with `is_active: true`:

| Call | Used for |
| - | - |
| [`GET /v1/campaigns/{campaign_id}/daily-stats`](https://docs.versionseven.ai/api-reference/campaigns/get-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`](https://docs.versionseven.ai/api-reference/campaigns/get-campaign-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`](https://docs.versionseven.ai/api-reference/campaigns/get-sender-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`](https://docs.versionseven.ai/api-reference/campaigns/get-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](https://docs.versionseven.ai/help/analytics-metrics) defines each number the same way the app does.

## The script

**Node.js**

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

**Python**

```python
# 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:

```text
: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:

```bash
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:

```yaml
# .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](https://docs.versionseven.ai/cookbook/sender-reconnect-monitor) handles the disconnected case on its own.
- **Runway under 7 days**: add leads, or let [Keep campaigns fed](https://docs.versionseven.ai/cookbook/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](https://docs.versionseven.ai/help/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

- [Alert on disconnected senders with a reconnect link](https://docs.versionseven.ai/cookbook/sender-reconnect-monitor), to fix the most common "no runway" cause automatically.
- [Keep campaigns fed](https://docs.versionseven.ai/cookbook/keep-campaigns-fed), to act on the runway flag instead of reading it.
- [Analytics metrics](https://docs.versionseven.ai/help/analytics-metrics) for what each number means.
