instantly-audit.py
1 #!/usr/bin/env python3 2 """ 3 instantly-audit.py 4 Pulls campaign data, account inventory, and warmup scores from the Instantly v2 API. 5 6 Usage: 7 python3 instantly-audit.py --api-key YOUR_KEY 8 python3 instantly-audit.py # uses INSTANTLY_API_KEY env var 9 python3 instantly-audit.py --api-key YOUR_KEY --output report.md 10 python3 instantly-audit.py --api-key YOUR_KEY --json # raw JSON output 11 12 Instantly v2 API docs: https://developer.instantly.ai/ 13 """ 14 15 import argparse 16 import json 17 import os 18 import sys 19 import time 20 from datetime import datetime 21 22 try: 23 import requests 24 except ImportError: 25 print("ERROR: 'requests' not installed. Run: pip install requests") 26 sys.exit(1) 27 28 BASE_URL = "https://api.instantly.ai/api/v2" 29 30 31 def get_headers(api_key: str) -> dict: 32 return { 33 "Authorization": f"Bearer {api_key}", 34 "Content-Type": "application/json", 35 } 36 37 38 def paginate(url: str, headers: dict, params: dict = None, limit: int = 100) -> list: 39 """Handle Instantly v2 cursor-based pagination.""" 40 results = [] 41 params = params or {} 42 params["limit"] = limit 43 starting_after = None 44 45 while True: 46 if starting_after: 47 params["starting_after"] = starting_after 48 49 try: 50 resp = requests.get(url, headers=headers, params=params, timeout=30) 51 except requests.exceptions.RequestException as e: 52 print(f" ā ļø Request failed: {e}") 53 break 54 55 if resp.status_code == 429: 56 retry_after = int(resp.headers.get("Retry-After", 5)) 57 print(f" ā³ Rate limited. Waiting {retry_after}s...") 58 time.sleep(retry_after) 59 continue 60 61 if resp.status_code == 401: 62 print(" š“ Authentication failed. Check your API key.") 63 sys.exit(1) 64 65 if not resp.ok: 66 print(f" ā ļø API error {resp.status_code}: {resp.text[:200]}") 67 break 68 69 data = resp.json() 70 items = data.get("items", data if isinstance(data, list) else []) 71 results.extend(items) 72 73 next_cursor = data.get("next_starting_after") or data.get("next_cursor") 74 if not next_cursor or len(items) < limit: 75 break 76 starting_after = next_cursor 77 78 return results 79 80 81 def fetch_campaigns(headers: dict) -> list: 82 """Fetch all campaigns with analytics.""" 83 print("š Fetching campaigns...") 84 campaigns = paginate(f"{BASE_URL}/campaigns", headers) 85 print(f" Found {len(campaigns)} campaigns") 86 return campaigns 87 88 89 def fetch_campaign_analytics(headers: dict, campaign_ids: list) -> dict: 90 """Fetch analytics summary for campaigns.""" 91 if not campaign_ids: 92 return {} 93 94 print("š Fetching campaign analytics...") 95 analytics = {} 96 97 for i in range(0, len(campaign_ids), 10): 98 batch = campaign_ids[i:i+10] 99 try: 100 resp = requests.get( 101 f"{BASE_URL}/campaigns/analytics/overview", 102 headers=headers, 103 params={"campaign_id": batch}, 104 timeout=30, 105 ) 106 if resp.ok: 107 data = resp.json() 108 if isinstance(data, dict): 109 analytics.update(data) 110 elif isinstance(data, list): 111 for item in data: 112 cid = item.get("campaign_id") or item.get("id") 113 if cid: 114 analytics[cid] = item 115 except requests.exceptions.RequestException as e: 116 print(f" ā ļø Analytics fetch failed for batch: {e}") 117 118 time.sleep(0.3) 119 120 return analytics 121 122 123 def fetch_accounts(headers: dict) -> list: 124 """Fetch all sending accounts with warmup status.""" 125 print("š§ Fetching sending accounts...") 126 accounts = paginate(f"{BASE_URL}/accounts", headers) 127 print(f" Found {len(accounts)} accounts") 128 return accounts 129 130 131 def fetch_warmup_scores(headers: dict, account_emails: list) -> dict: 132 """Fetch warmup analytics for accounts.""" 133 if not account_emails: 134 return {} 135 136 print("š„ Fetching warmup scores...") 137 warmup_data = {} 138 139 for email in account_emails: 140 try: 141 resp = requests.get( 142 f"{BASE_URL}/accounts/{email}/warmup/analytics", 143 headers=headers, 144 timeout=30, 145 ) 146 if resp.ok: 147 warmup_data[email] = resp.json() 148 elif resp.status_code == 404: 149 warmup_data[email] = {"score": None, "status": "no_warmup_data"} 150 time.sleep(0.1) 151 except requests.exceptions.RequestException: 152 warmup_data[email] = {"score": None, "status": "fetch_error"} 153 154 return warmup_data 155 156 157 def assess_warmup_readiness(account: dict, warmup: dict) -> tuple: 158 """Return (ready: bool, issues: list) for an account.""" 159 issues = [] 160 161 score = (warmup.get("warmup_score") or warmup.get("score") 162 or account.get("stat_warmup_score") or account.get("warmup_score")) 163 if score is None: 164 issues.append("No warmup data available") 165 elif score < 80: 166 issues.append(f"Warmup score {score} < 80 (minimum required)") 167 168 warmup_start = account.get("warmup_start_date") or account.get("created_at") 169 if warmup_start: 170 try: 171 start_dt = datetime.fromisoformat(warmup_start.replace("Z", "+00:00")) 172 days_warmed = (datetime.now(start_dt.tzinfo) - start_dt).days 173 if days_warmed < 14: 174 issues.append(f"Only {days_warmed} days warmed (need 14+)") 175 except (ValueError, AttributeError): 176 pass 177 178 status = str(account.get("status", "")).lower() 179 if status in ("paused", "error", "suspended", "disabled"): 180 issues.append(f"Account status: {status}") 181 182 ready = len(issues) == 0 183 return ready, issues 184 185 186 def format_pct(value, total, decimals=1) -> str: 187 if not total: 188 return "N/A" 189 return f"{(value / total * 100):.{decimals}f}%" 190 191 192 def generate_report(campaigns: list, analytics: dict, accounts: list, warmup_scores: dict) -> str: 193 lines = [] 194 now = datetime.now().strftime("%Y-%m-%d %H:%M") 195 196 lines.append(f"# Instantly Audit Report") 197 lines.append(f"Generated: {now}\n") 198 199 # āā Account Inventory āā 200 lines.append("## Sending Account Inventory\n") 201 202 ready_accounts = [] 203 not_ready_accounts = [] 204 205 for acct in accounts: 206 email = acct.get("email", "unknown") 207 warmup = warmup_scores.get(email, {}) 208 ready, issues = assess_warmup_readiness(acct, warmup) 209 score = (warmup.get("warmup_score") or warmup.get("score") 210 or acct.get("stat_warmup_score") or acct.get("warmup_score") or "N/A") 211 daily_limit = acct.get("sending_limit") or acct.get("daily_limit", 30) 212 213 row = { 214 "email": email, 215 "status": acct.get("status", "unknown"), 216 "warmup_score": score, 217 "daily_limit": daily_limit, 218 "ready": ready, 219 "issues": issues, 220 } 221 222 if ready: 223 ready_accounts.append(row) 224 else: 225 not_ready_accounts.append(row) 226 227 total_accounts = len(accounts) 228 total_ready = len(ready_accounts) 229 230 lines.append(f"**Total accounts:** {total_accounts}") 231 lines.append(f"**Ready to send:** {total_ready} ā ") 232 lines.append(f"**Not ready:** {len(not_ready_accounts)} ā ļø\n") 233 234 if ready_accounts: 235 conservative_daily = total_ready * 30 236 aggressive_daily = total_ready * 50 237 conservative_monthly = conservative_daily * 22 238 aggressive_monthly = aggressive_daily * 22 239 lines.append("### Capacity Math (ready accounts only)") 240 lines.append(f"- Conservative (30/day/account): **{conservative_daily:,}/day ā {conservative_monthly:,}/month**") 241 lines.append(f"- Aggressive (50/day/account): **{aggressive_daily:,}/day ā {aggressive_monthly:,}/month**\n") 242 243 lines.append("### ā Ready Accounts") 244 if ready_accounts: 245 lines.append("| Account | Status | Warmup Score | Daily Limit |") 246 lines.append("|---------|--------|-------------|------------|") 247 for a in ready_accounts: 248 lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {a['daily_limit']} |") 249 else: 250 lines.append("_None ā no accounts meet warmup requirements_") 251 252 lines.append("\n### ā ļø Not Ready Accounts") 253 if not_ready_accounts: 254 lines.append("| Account | Status | Warmup Score | Issues |") 255 lines.append("|---------|--------|-------------|--------|") 256 for a in not_ready_accounts: 257 issues_str = "; ".join(a["issues"]) if a["issues"] else "unknown" 258 lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {issues_str} |") 259 else: 260 lines.append("_None ā all accounts are ready_") 261 262 # āā Campaign Performance āā 263 lines.append("\n---\n## Campaign Performance\n") 264 lines.append(f"**Total campaigns:** {len(campaigns)}\n") 265 266 if not campaigns: 267 lines.append("_No campaigns found_") 268 else: 269 lines.append("| Campaign | Status | Sent | Open Rate | Reply Rate | Positive Reply Rate |") 270 lines.append("|----------|--------|------|-----------|-----------|-------------------|") 271 272 for c in campaigns: 273 cid = c.get("id", "") 274 name = c.get("name", "Unnamed")[:50] 275 status = c.get("status", "unknown") 276 277 a = analytics.get(cid, {}) 278 sent = a.get("emails_sent", 0) or c.get("emails_sent", 0) 279 opened = a.get("emails_opened", 0) 280 replied = a.get("emails_replied", 0) 281 positive = a.get("positive_replies", 0) 282 283 open_rate = format_pct(opened, sent) 284 reply_rate = format_pct(replied, sent) 285 pos_rate = format_pct(positive, sent) 286 287 lines.append(f"| {name} | {status} | {sent:,} | {open_rate} | {reply_rate} | {pos_rate} |") 288 289 # āā Flags & Recommendations āā 290 lines.append("\n---\n## Flags & Recommendations\n") 291 292 flags = [] 293 294 if total_ready == 0: 295 flags.append("š“ **BLOCKER:** No accounts are ready to send. All fail warmup requirements. Do not launch campaigns.") 296 elif total_ready < 3: 297 flags.append(f"ā ļø Only {total_ready} account(s) ready. Low volume capacity. Consider warming more accounts.") 298 299 low_open = [] 300 low_reply = [] 301 for c in campaigns: 302 cid = c.get("id", "") 303 a = analytics.get(cid, {}) 304 sent = a.get("emails_sent", 0) 305 if sent < 50: 306 continue 307 opened = a.get("emails_opened", 0) 308 replied = a.get("emails_replied", 0) 309 open_pct = (opened / sent * 100) if sent else 0 310 reply_pct = (replied / sent * 100) if sent else 0 311 if open_pct < 40: 312 low_open.append(c.get("name", cid)) 313 if reply_pct < 3: 314 low_reply.append(c.get("name", cid)) 315 316 if low_open: 317 flags.append(f"ā ļø Low open rate (<40%) campaigns (subject line issue): {', '.join(low_open[:5])}") 318 if low_reply: 319 flags.append(f"ā ļø Low reply rate (<3%) campaigns (copy/offer issue): {', '.join(low_reply[:5])}") 320 321 if not flags: 322 flags.append("ā No critical flags detected.") 323 324 for f in flags: 325 lines.append(f"- {f}") 326 327 lines.append(f"\n---\n_Audit complete. {total_accounts} accounts, {len(campaigns)} campaigns analyzed._") 328 return "\n".join(lines) 329 330 331 def main(): 332 parser = argparse.ArgumentParser(description="Instantly v2 API Audit Tool") 333 parser.add_argument("--api-key", help="Instantly API key (or set INSTANTLY_API_KEY env var)") 334 parser.add_argument("--output", help="Write report to this file (default: print to stdout)") 335 parser.add_argument("--json", action="store_true", help="Output raw JSON instead of markdown report") 336 args = parser.parse_args() 337 338 api_key = args.api_key or os.environ.get("INSTANTLY_API_KEY") 339 if not api_key: 340 api_key = input("Instantly API key: ").strip() 341 if not api_key: 342 print("ERROR: API key required. Set INSTANTLY_API_KEY env var or pass --api-key.") 343 sys.exit(1) 344 345 headers = get_headers(api_key) 346 347 print(f"\nš Starting Instantly audit...\n") 348 349 campaigns = fetch_campaigns(headers) 350 campaign_ids = [c.get("id") for c in campaigns if c.get("id")] 351 analytics = fetch_campaign_analytics(headers, campaign_ids) 352 353 accounts = fetch_accounts(headers) 354 account_emails = [a.get("email") for a in accounts if a.get("email")] 355 warmup_scores = fetch_warmup_scores(headers, account_emails) 356 357 if args.json: 358 output = json.dumps({ 359 "campaigns": campaigns, 360 "analytics": analytics, 361 "accounts": accounts, 362 "warmup_scores": warmup_scores, 363 }, indent=2, default=str) 364 else: 365 output = generate_report(campaigns, analytics, accounts, warmup_scores) 366 367 if args.output: 368 with open(args.output, "w") as f: 369 f.write(output) 370 print(f"\nā Report written to: {args.output}") 371 else: 372 print("\n" + output) 373 374 375 if __name__ == "__main__": 376 main()