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
- Slack
- cron
- GitHub Actions
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:writeand, 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, defaultreconnect-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
GET /v1/accountslists every connected sender withis_active.falsemeans the account needs to be reconnected.- For each disconnected account without an unexpired link in the state file,
POST /v1/accounts/connect-linkwithreconnect_account_idmints a hosted sign-in link for that account. It takes no seat, and it expires atexpires_at, about a week later. - 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.
- With
--pause: for each active campaign,GET /v1/campaigns/{campaign_id}/queuereportswarnings; when they includeevery enabled sender account is disconnected,PATCH /v1/campaigns/{campaign_id}withis_active: falsepauses 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));# monitor.py
import json
import os
import sys
import time
from datetime import datetime, 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")
STATE_FILE = os.environ.get("STATE_FILE", "reconnect-state.json")
PAUSE = "--pause" in sys.argv
NO_SENDER_WARNING = "every enabled sender account is disconnected"
def call(method, path, body=None):
"""Sends a request, waiting and retrying on 429 and 503."""
for attempt in range(1, 6):
response = requests.request(method, f"{API_URL}{path}", headers=HEADERS, json=body, timeout=30)
try:
data = response.json()
except ValueError:
data = {}
if response.status_code not in (429, 503) or attempt == 5:
return response.status_code, data
time.sleep(float(response.headers.get("Retry-After") or 2**attempt))
def list_all(path, key):
"""Every page of a list endpoint."""
items, offset = [], 0
while True:
status, data = call("GET", f"{path}?limit=500&offset={offset}")
if status != 200:
raise SystemExit(f"{path} answered {status} {data.get('error')}")
items.extend(data[key])
if not data["has_more"]:
return items
offset += data["count"]
def notify(text):
if not SLACK_WEBHOOK_URL:
print(text)
return
response = requests.post(SLACK_WEBHOOK_URL, json={"text": text}, timeout=10)
response.raise_for_status()
def parse_time(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def main():
try:
with open(STATE_FILE, encoding="utf-8") as file:
state = json.load(file)
except (FileNotFoundError, ValueError):
state = {}
now = datetime.now(timezone.utc)
summary = {"disconnected": 0, "links_sent": 0, "paused": 0}
for account in list_all("/accounts", "accounts"):
label = account.get("platform_username") or account.get("email") or account["id"]
if account.get("is_active") is not False:
state.pop(account["id"], None) # reconnected: the next disconnect gets a fresh link
continue
summary["disconnected"] += 1
sent = state.get(account["id"])
if sent and parse_time(sent["expires_at"]) > now:
continue # already told the team, link still valid
status, data = call(
"POST",
"/accounts/connect-link",
{
"platform": "linkedin" if account.get("platform") == "linkedin" else "email",
"display_name": label,
"email": account.get("email"),
"reconnect_account_id": account["id"],
},
)
if status != 200:
print(f"Couldn't mint a link for {label}: {status} {data.get('error')} {data.get('message', '')}", file=sys.stderr)
continue
notify(
f":warning: Sender *{label}* ({account.get('platform')}) is disconnected. "
f"Reconnect it here before {data['expires_at'][:10]}: {data['url']}"
)
state[account["id"]] = {"url": data["url"], "expires_at": data["expires_at"], "sent_at": now.isoformat()}
summary["links_sent"] += 1
time.sleep(0.65)
if PAUSE:
for campaign in list_all("/campaigns", "campaigns"):
if not campaign.get("is_active"):
continue
status, data = call("GET", f"/campaigns/{campaign['id']}/queue")
if status != 200 or NO_SENDER_WARNING not in (data.get("warnings") or []):
continue
status, data = call("PATCH", f"/campaigns/{campaign['id']}", {"is_active": False})
if status != 200:
print(f"Couldn't pause {campaign['name']}: {status} {data.get('error')}", file=sys.stderr)
continue
notify(f":pause_button: Paused *{campaign['name']}*: every sender on it is disconnected. Activate it again once one is back.")
summary["paused"] += 1
time.sleep(0.65)
with open(STATE_FILE, "w", encoding="utf-8") as file:
json.dump(state, file, indent=2)
print(json.dumps(summary))
if __name__ == "__main__":
main()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 --pauseIt 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>&1On 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_activeisfalsefor 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-linkcan answer400 INVALID_ACCOUNT(no account with that id in this organization) or400 VALIDATION_ERRORwhen the account never finished connecting; the script logs those and moves on. A mailbox needs itsemail, whichGET /accountsalready 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}andis_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
- Troubleshooting: sender disconnected for why accounts disconnect and how to prevent it.
- Receive replies once and fan them out to get replies into the same Slack channel.
- Rate limits if you run this against many workspaces at once.