# Stop outreach to people who became customers

A script that removes a person, or everyone at a company, from every campaign the moment they sign up or ask to be left alone, by hand or from a webhook.

Works with: Stripe.

Endpoints used:

- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`GET /v1/leads`](https://docs.versionseven.ai/api-reference/leads/list-leads) List leads
- [`DELETE /v1/leads/{lead_id}`](https://docs.versionseven.ai/api-reference/leads/remove-lead-from-campaign) Delete a lead or remove it from a campaign

The worst cold email is the one that lands the day after someone bought. Victoria AI stops a sequence when a lead replies, but a person who signs up on your website without replying, or a colleague of a new customer who's in the same campaign, keeps getting messages until someone removes them. This recipe is that someone: give it an email or a company domain and it takes every matching lead out of every campaign, in one run. Wire it to your billing system's "new customer" event and it runs itself.

## Before you start

- An API key with `campaigns:read`, `leads:read` and `leads:write` in `VICTORIA_API_KEY`. See [Authentication](https://docs.versionseven.ai/guides/authentication).
- The email addresses or domains to stop. A domain removes everyone at the company; an email removes one person.
- Node.js 18 or later, or Python 3.10 or later with `requests`.

## How it works

1. [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) lists every campaign, active or paused. A paused campaign can be activated again, so its leads are removed too.
2. For each campaign and each term, [`GET /v1/leads`](https://docs.versionseven.ai/api-reference/leads/list-leads) with `campaign_id` and `search` finds candidates, and the script keeps only exact matches: the lead's email equals the term, or the part after `@` equals the domain. `search` is a substring match on name, email and company, so the exact check matters: `acme.com` would otherwise also find `notacme.com`.
3. [`DELETE /v1/leads/{lead_id}`](https://docs.versionseven.ai/api-reference/leads/remove-lead-from-campaign) with `campaign_id` removes the lead from that campaign only. The lead stays in your workspace with its history; it just isn't contacted by that campaign anymore. With `--delete`, the lead itself is deleted instead, from every campaign at once.

## The script

**Node.js**

```javascript
// stop-outreach.mjs
const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const args = process.argv.slice(2);
const DELETE_LEADS = args.includes("--delete");
const terms = args.filter((arg) => !arg.startsWith("--")).map((term) => term.trim().toLowerCase());
if (terms.length === 0) throw new Error("Usage: node stop-outreach.mjs [--delete] <email or domain> [...]");

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

// Sends a request, waiting and retrying on 429 and 503.
async function call(method, path) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${API_URL}${path}`, { method, headers: { Authorization: `Bearer ${API_KEY}` } });
    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);
  }
}

async function listAll(path, key) {
  const items = [];
  for (let offset = 0; ; ) {
    const { status, data } = await call("GET", `${path}${path.includes("?") ? "&" : "?"}limit=100&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;
  }
}

// An exact match on the email, or on the email's domain for a domain term.
function matches(lead, term) {
  const email = (lead.email ?? "").toLowerCase();
  if (!email) return false;
  return term.includes("@") ? email === term : email.split("@")[1] === term;
}

const summary = { campaigns: 0, matched: 0, removed: 0, deleted: 0, failed: 0 };
const deletedLeads = new Set();

for (const campaign of await listAll("/campaigns", "campaigns")) {
  summary.campaigns++;
  for (const term of terms) {
    const candidates = await listAll(`/leads?campaign_id=${campaign.id}&search=${encodeURIComponent(term)}`, "leads");
    for (const lead of candidates.filter((candidate) => matches(candidate, term))) {
      summary.matched++;
      if (DELETE_LEADS) {
        if (deletedLeads.has(lead.id)) continue; // already deleted through an earlier campaign
        const { status, data } = await call("DELETE", `/leads/${lead.id}`);
        if (status === 200) {
          deletedLeads.add(lead.id);
          summary.deleted++;
          console.log(`deleted  ${lead.email}`);
        } else {
          summary.failed++;
          console.log(`failed   ${lead.email}: ${status} ${data.error}`);
        }
      } else {
        const { status, data } = await call("DELETE", `/leads/${lead.id}?campaign_id=${campaign.id}`);
        if (status === 200) {
          summary.removed++;
          console.log(`removed  ${lead.email} from ${campaign.name}`);
        } else {
          summary.failed++;
          console.log(`failed   ${lead.email} in ${campaign.name}: ${status} ${data.error}`);
        }
      }
      await sleep(650);
    }
  }
}

console.log(JSON.stringify(summary));
```

**Python**

```python
# stop_outreach.py
import json
import os
import sys
import time
from urllib.parse import quote

import requests

API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
DELETE_LEADS = "--delete" in sys.argv
TERMS = [arg.strip().lower() for arg in sys.argv[1:] if not arg.startswith("--")]
if not TERMS:
    raise SystemExit("Usage: python stop_outreach.py [--delete] <email or domain> [...]")


def call(method, path):
    """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, 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):
    items, offset = [], 0
    joiner = "&" if "?" in path else "?"
    while True:
        status, data = call("GET", f"{path}{joiner}limit=100&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 matches(lead, term):
    """An exact match on the email, or on the email's domain for a domain term."""
    email = (lead.get("email") or "").lower()
    if not email:
        return False
    return email == term if "@" in term else email.split("@")[1] == term


def main():
    summary = {"campaigns": 0, "matched": 0, "removed": 0, "deleted": 0, "failed": 0}
    deleted_leads = set()
    for campaign in list_all("/campaigns", "campaigns"):
        summary["campaigns"] += 1
        for term in TERMS:
            candidates = list_all(f"/leads?campaign_id={campaign['id']}&search={quote(term)}", "leads")
            for lead in (c for c in candidates if matches(c, term)):
                summary["matched"] += 1
                if DELETE_LEADS:
                    if lead["id"] in deleted_leads:
                        continue  # already deleted through an earlier campaign
                    status, data = call("DELETE", f"/leads/{lead['id']}")
                    if status == 200:
                        deleted_leads.add(lead["id"])
                        summary["deleted"] += 1
                        print(f"deleted  {lead['email']}")
                    else:
                        summary["failed"] += 1
                        print(f"failed   {lead['email']}: {status} {data.get('error')}")
                else:
                    status, data = call("DELETE", f"/leads/{lead['id']}?campaign_id={campaign['id']}")
                    if status == 200:
                        summary["removed"] += 1
                        print(f"removed  {lead['email']} from {campaign['name']}")
                    else:
                        summary["failed"] += 1
                        print(f"failed   {lead['email']} in {campaign['name']}: {status} {data.get('error')}")
                time.sleep(0.65)
    print(json.dumps(summary))


if __name__ == "__main__":
    main()
```

```bash
node stop-outreach.mjs acmecorp.com                      # everyone at Acme, out of every campaign
node stop-outreach.mjs sarah.johnson@acmecorp.com        # one person
node stop-outreach.mjs --delete sarah.johnson@acmecorp.com   # delete the lead itself
python stop_outreach.py acmecorp.com northwind.com
```

## Run it from your billing system

The natural trigger is "a company became a customer". With Stripe, that's the `customer.created` or `customer.subscription.created` event: a tiny webhook endpoint (verify the event with Stripe's SDK), read the customer's email, and run the script with the email's domain. The same shape works for any system that can call a URL when an account is created. [Start outreach from a product event](https://docs.versionseven.ai/cookbook/trigger-outreach-from-product-events) shows that endpoint pattern for the opposite direction; the body of the handler here is a `child_process.spawn` or `subprocess.run` of this script, or the loop above inlined.

## What this does and doesn't do

- **Removing from a campaign stops that campaign's messages to the lead.** Leads already in a conversation with the Appointment Setter stop too: a removed lead has no sequence to continue.
- **There is no Do Not Contact endpoint.** The list that blocks a person or domain from ever being added again lives in the app under **Settings → Do Not Contact**, and the API reads it on every `POST /leads` (which then answers `409 LEAD_SUPPRESSED`) but can't write to it. So a later import or sync can re-create the lead unless someone adds the company there too; [Do Not Contact](https://docs.versionseven.ai/help/do-not-contact) covers how. For customers, that one manual step is worth making part of your onboarding checklist.
- `--delete` erases the lead from your workspace, including its conversation history and any CRM deal's link to it. Use it for people who asked to be forgotten; for customers, removing from campaigns keeps the record.
- Leads without an email (LinkedIn-only) can't be matched by email or domain. Find them in the app by name, or extend `matches` to compare `linkedin_url`.

## Next steps

- [Do Not Contact](https://docs.versionseven.ai/help/do-not-contact) for the list that keeps a company out for good.
- [Start outreach from a product event](https://docs.versionseven.ai/cookbook/trigger-outreach-from-product-events), the opposite direction: a product event that starts a sequence.
- [Add CRM contacts to a campaign](https://docs.versionseven.ai/cookbook/add-crm-contacts) for how leads are matched by email and LinkedIn URL.
