> ## Documentation Index
> Fetch the complete documentation index at: https://mint.skeptrune.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Solving Org-Level Email Campaigns with Instantly Webhooks

> No mainstream email tool stops a whole org when one person replies. Build org-level campaign control with Instantly, webhooks, and a small Node.js server.

When we launched new [community Discord and Slack bots](https://www.mintlify.com/docs/ai/discord#discord-bot) at Mintlify, we needed to do some customer marketing to let users with large communities know about them. The goal was straightforward: send an email sequence to the 3–10 people from each organization who could get value from the feature. Once one person from an org enables the bot, the task is complete — there's no reason to keep emailing everyone else from the same company. I thought any of the email marketing tools out there would handle this trivially. They don't. Every tool available handles campaigns at the user level, not the organization level. None of them have the concept of "stop emailing this group when any member takes action." They're all built for individual drip campaigns, not org-level outreach.

## Defining the goal

The problem is specific but common in B2B:

* You have multiple contacts per account — typically 3–10 people per org.
* You're marketing a feature where **one conversion per org is enough**.
* Once anyone from the org takes action (replies, signs up, enables the feature), you want to stop the sequence for everyone else from that org.
* You don't want to spam remaining contacts after the task is already done.

<Warning>
  None of the mainstream tools — [Resend](https://resend.com/), [Loops](https://loops.so/), or others — support this natively. They treat every contact as an individual and have no concept of group-level stopping conditions.
</Warning>

## Tool selection

[Claude Code](https://www.anthropic.com/claude) has made me quite lazy insofar as I try to get everything done using it instead of doing things manually. My number one criteria when picking a tool to solve this was a large API surface that Claude could work with effectively.

[Instantly](https://instantly.ai) was the final selection given its API was the most robust and [well documented](https://developer.instantly.ai/api/v2/analytics/getwarmupanalytics). It gave Claude full access to campaigns, leads, and sequences, while also being capable of sending webhooks on reply and unsubscribe events.

<Note>
  Kind of a random aside, but [JAMstack](https://jamstack.org/) architecture patterns are probably going to make a comeback with AI. Tools like [tRPC](https://trpc.io/) are going to fall out of favor relative to [OpenAPI](https://www.openapis.org/)-driven patterns that AI agents can better understand. Separation of UI and business logic is the way forward if you want your apps to be accessible to AI agents.
</Note>

## Solution architecture

The Instantly campaign holds the multi-email sequence and is configured to stop on reply. A lead upload script reads a CSV of contacts, groups them by company, and uploads them to Instantly with a `companyName` custom variable.

Then a webhook server listens for reply events, finds all leads from the same company, and marks them as "not interested" to stop their sequences.

```text theme={null}
CSV (company, email)
    → Upload Script
    → Instantly (leads with companyName)

Reply received
    → Instantly webhook
    → Webhook server
    → Find leads by companyName
    → Update lead status
    → Sequence stops for whole company
```

### Creating the campaign

The campaign itself is straightforward. Set `stop_on_reply` to `true` so Instantly stops the sequence for whoever replies, then define your email steps with delays between them.

```bash theme={null}
curl -X POST https://api.instantly.ai/api/v2/campaigns \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Feature Launch Outreach",
    "stop_on_reply": true,
    "stop_on_auto_reply": true,
    "sequences": [{
      "steps": [
        {"type": "email", "delay": 0, "variants": [{"subject": "...", "body": "..."}]},
        {"type": "email", "delay": 3, "variants": [{"subject": "...", "body": "..."}]}
      ]
    }]
  }'
```

### Uploading leads

The critical detail is including a `companyName` custom variable with every lead. The webhook server uses that field to find all leads from the same company and unsubscribe them when a relevant event fires.

```python theme={null}
import csv
import requests

API_KEY = "your-api-key"
CAMPAIGN_ID = "your-campaign-id"

with open("contacts.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        emails = row["emails"].split(";")
        company = row["company_name"]

        for email in emails:
            requests.post(
                "https://api.instantly.ai/api/v2/leads",
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "email": email.strip(),
                    "company_name": company,
                    "custom_variables": {"companyName": company},
                    "campaign": CAMPAIGN_ID
                }
            )
```

### Registering the webhook

Tell Instantly to POST to your server whenever someone replies. You'll need the campaign ID from the previous step.

```bash theme={null}
curl -X POST https://api.instantly.ai/api/v2/webhooks \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_hook_url": "https://your-server.com/webhook/reply",
    "event_type": "reply_received",
    "campaign_id": "your-campaign-id"
  }'
```

## Webhook server

This is the part that makes it all work. When anyone replies, the webhook server extracts the company name, queries Instantly for all leads with that company name, and updates each lead's status to stop their sequence.

```javascript theme={null}
const express = require('express');
const app = express();
app.use(express.json());

const API_KEY = process.env.INSTANTLY_API_KEY;
const CAMPAIGN_ID = process.env.CAMPAIGN_ID;

app.post('/webhook/reply', async (req, res) => {
  const event = req.body;

  if (event.event_type !== 'reply_received') {
    return res.json({ status: 'ignored' });
  }

  const leadEmail = event.lead_email;
  const companyName = event.lead?.company_name;

  if (!companyName) {
    return res.json({ status: 'ignored', reason: 'no company' });
  }

  // Find all leads from this company
  const leads = await findLeadsByCompany(companyName);

  // Stop all of them (except the one who replied — they're already stopped)
  for (const lead of leads) {
    if (lead.email !== leadEmail) {
      await stopLead(lead.email);
    }
  }

  res.json({ status: 'ok', stopped: leads.length - 1 });
});

// Instantly's API doesn't let you filter by company_name server-side.
// The search param only works on name/email. So we fetch all leads
// and filter client-side. For large campaigns, you'd want to cache
// this or build your own company->leads index.
async function findLeadsByCompany(companyName) {
  const leads = [];
  let cursor = null;

  do {
    const resp = await fetch('https://api.instantly.ai/api/v2/leads/list', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ campaign: CAMPAIGN_ID, starting_after: cursor })
    });
    const data = await resp.json();
    leads.push(...data.items);
    cursor = data.next_starting_after;
  } while (cursor);

  return leads.filter(lead => lead.company_name === companyName);
}

async function stopLead(email) {
  await fetch('https://api.instantly.ai/api/v2/leads/update-interest-status', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ lead_email: email, interest_value: -1 })
  });
}

app.listen(3000);
```

<Note>
  Instantly's API does not support filtering leads by `company_name` server-side — the search parameter only works on name and email fields. The implementation above fetches all leads for the campaign and filters client-side. For large campaigns you'd want to cache the result or build your own company-to-leads index to avoid repeated full paginations on every reply event.
</Note>

### Hosting options

The server is stateless, so any hosting that can run Node.js works.

<Tabs>
  <Tab title="VPS (recommended)">
    A cheap VPS running Docker behind Caddy handles SSL termination and is the simplest long-term option. You control the machine and there are no cold starts.
  </Tab>

  <Tab title="Serverless">
    Cloudflare Workers, AWS Lambda, Vercel Functions, and Railway all work. Serverless is convenient but watch for cold starts on infrequent webhook traffic — a reply arriving after a long idle period may time out before the function warms up.
  </Tab>

  <Tab title="PaaS">
    Render and Railway both offer always-on free or low-cost tiers that are a reasonable middle ground between full VPS management and pure serverless.
  </Tab>
</Tabs>

## Someone please build this

This solution works, but it's more complex than it should be. The fact that I had to build a webhook server to get org-level behavior is absurd. This should be a checkbox in every B2B email tool.

What the ideal interface looks like:

<AccordionGroup>
  <Accordion title="Group contacts by company at campaign creation">
    Define groups of contacts by company when setting up a campaign, the same way you'd define a segment. The tool owns the grouping — you don't have to encode it in a custom variable and maintain a separate lookup.
  </Accordion>

  <Accordion title="Toggle 'stop group on reply' as a campaign setting">
    A single boolean on the campaign: if any member of a company group takes action (replies, clicks, converts), the tool stops the sequence for all remaining members of that group automatically.
  </Accordion>

  <Accordion title="No webhooks required">
    The entire org-level stopping logic should live inside the email tool itself. A webhook server, client-side lead pagination, and a custom interest-status update should not be necessary for this use case.
  </Accordion>
</AccordionGroup>

If you're building B2B email tooling, please add this. It's a genuinely common pattern for any feature launch or outreach campaign that targets multiple stakeholders per account.
