Cookbook
View as Markdown

Alert on disconnected senders with a reconnect link

A scheduled script that finds disconnected senders, mints a reconnect link for each, posts it to Slack once, and can pause campaigns left without a sender.

Last updated

A LinkedIn session expires, a mailbox password changes, and a sender stops sending until someone signs in again. The app shows this on Sender Accounts and emails the owner, but a team that lives in Slack wants the reconnect link there, the moment it happens. This recipe is a script to run every hour: it lists the sender accounts, mints a reconnect link for each one that's disconnected, posts the link once, and remembers what it sent so the next run doesn't repeat it. With --pause, it also pauses any active campaign whose every sender is disconnected.

Before you start

  • An API key with accounts:write and, for --pause, campaigns:write. See Authentication.
  • A Slack incoming webhook URL in SLACK_WEBHOOK_URL. Without it, the script prints the links instead.
  • Somewhere to keep a small state file between runs (STATE_FILE, default reconnect-state.json). On GitHub Actions, cache it or commit it; on a server, any writable path.
  • Node.js 18 or later, or Python 3.10 or later with requests.

How it works

  1. GET /v1/accounts lists every connected sender with is_active. false means the account needs to be reconnected.
  2. For each disconnected account without an unexpired link in the state file, POST /v1/accounts/connect-link with reconnect_account_id mints a hosted sign-in link for that account. It takes no seat, and it expires at expires_at, about a week later.
  3. The link goes to Slack with the account's name, and the state file records it, so a later run stays quiet until the link has expired or the account is active again.
  4. With --pause: for each active campaign, GET /v1/campaigns/{campaign_id}/queue reports warnings; when they include every enabled sender account is disconnected, PATCH /v1/campaigns/{campaign_id} with is_active: false pauses it, so leads aren't marked contacted by a sender that can't send.

The script

// monitor.mjs
import { readFile, writeFile } from "node:fs/promises";

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 STATE_FILE = process.env.STATE_FILE ?? "reconnect-state.json";
const PAUSE = process.argv.includes("--pause");
const NO_SENDER_WARNING = "every enabled sender account is disconnected";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Sends a request, waiting and retrying on 429 and 503.
async function call(method, path, body) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${API_URL}${path}`, {
      method,
      headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const data = await response.json().catch(() => ({}));
    if ((response.status !== 429 && response.status !== 503) || attempt === 5) return { status: response.status, data };
    await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
  }
}

// Every page of a list endpoint.
async function listAll(path, key) {
  const items = [];
  for (let offset = 0; ; ) {
    const { status, data } = await call("GET", `${path}?limit=500&offset=${offset}`);
    if (status !== 200) throw new Error(`${path} answered ${status} ${data.error}`);
    items.push(...data[key]);
    if (!data.has_more) return items;
    offset += data.count;
  }
}

async function notify(text) {
  if (!SLACK_WEBHOOK_URL) return console.log(text);
  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}`);
}

const state = await readFile(STATE_FILE, "utf8").then(JSON.parse).catch(() => ({}));
const now = new Date();
const summary = { disconnected: 0, links_sent: 0, paused: 0 };

const accounts = await listAll("/accounts", "accounts");
for (const account of accounts) {
  const label = account.platform_username ?? account.email ?? account.id;
  if (account.is_active !== false) {
    delete state[account.id]; // reconnected: the next disconnect gets a fresh link
    continue;
  }
  summary.disconnected++;
  const sent = state[account.id];
  if (sent && new Date(sent.expires_at) > now) continue; // already told the team, link still valid

  const { status, data } = await call("POST", "/accounts/connect-link", {
    platform: account.platform === "linkedin" ? "linkedin" : "email",
    display_name: label,
    email: account.email,
    reconnect_account_id: account.id,
  });
  if (status !== 200) {
    console.error(`Couldn't mint a link for ${label}: ${status} ${data.error} ${data.message ?? ""}`);
    continue;
  }
  await notify(
    `:warning: Sender *${label}* (${account.platform}) is disconnected. Reconnect it here before ${data.expires_at.slice(0, 10)}: ${data.url}`
  );
  state[account.id] = { url: data.url, expires_at: data.expires_at, sent_at: now.toISOString() };
  summary.links_sent++;
  await sleep(650);
}

if (PAUSE) {
  const campaigns = (await listAll("/campaigns", "campaigns")).filter((campaign) => campaign.is_active);
  for (const campaign of campaigns) {
    const { status, data } = await call("GET", `/campaigns/${campaign.id}/queue`);
    if (status !== 200 || !data.warnings?.includes(NO_SENDER_WARNING)) continue;
    const paused = await call("PATCH", `/campaigns/${campaign.id}`, { is_active: false });
    if (paused.status !== 200) {
      console.error(`Couldn't pause ${campaign.name}: ${paused.status} ${paused.data.error}`);
      continue;
    }
    await notify(`:pause_button: Paused *${campaign.name}*: every sender on it is disconnected. Activate it again once one is back.`);
    summary.paused++;
    await sleep(650);
  }
}

await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
console.log(JSON.stringify(summary));

Run it by hand first:

node monitor.mjs            # alerts only
node monitor.mjs --pause    # alerts, and pauses campaigns with no working sender
python monitor.py --pause

It prints a summary such as {"disconnected": 1, "links_sent": 1, "paused": 0}. Run it again and links_sent is 0: the state file remembers the link until it expires.

Schedule it

On a server, a crontab line runs it hourly:

0 * * * * cd /opt/victoria-monitor && VICTORIA_API_KEY=vk_… SLACK_WEBHOOK_URL=https://hooks.slack.com/… node monitor.mjs --pause >> monitor.log 2>&1

On GitHub Actions, keep the state file in the cache so runs don't repeat links:

# .github/workflows/sender-monitor.yml
name: Sender monitor
on:
  schedule:
    - cron: "0 * * * *"
  workflow_dispatch:
jobs:
  monitor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - uses: actions/cache@v4
        with:
          path: reconnect-state.json
          key: reconnect-state-${{ github.run_id }}
          restore-keys: reconnect-state-
      - run: node monitor.mjs --pause
        env:
          VICTORIA_API_KEY: ${{ secrets.VICTORIA_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

What to expect

  • is_active is false for an account that has disconnected or was deactivated. Both get a link; a deactivated account's owner can sign in again the same way.
  • A reconnect link re-authenticates the same account, so campaigns keep their sender assignment and its warm-up ramp; nothing needs re-assigning afterwards.
  • POST /accounts/connect-link can answer 400 INVALID_ACCOUNT (no account with that id in this organization) or 400 VALIDATION_ERROR when the account never finished connecting; the script logs those and moves on. A mailbox needs its email, which GET /accounts already returns.
  • Pausing never runs the activation preflight. Activating again does, so after the sender is back, a person activates the campaign in the app, with PATCH /v1/campaigns/{campaign_id} and is_active: true, or by asking a connected assistant.
  • The app also emails the sender's owner when an account disconnects. See Reconnect a sender for what the person clicking the link sees.

Next steps