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

# Follow up on campaign replies and clicks

> Send campaign replies to your CRM for a rep to pick up, and give prospects who clicked but never replied a second sequence with a fresh angle.

A campaign's work is not done when the last email goes out. Replies need a person to pick them up quickly, and prospects who clicked but never answered are the warmest leads you have not converted. This workflow reads a campaign's leads, hands everyone who replied to your CRM, and gives the people who clicked and finished the sequence without replying a second campaign with a different angle.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[Campaign leads] -->|replied| B[Repliers list]
  B -->|crm-push| C[Your CRM]
  A -->|clicked, no reply| D[Clickers list]
  D -->|listIds| E[Second-touch campaign]
```

## Before you start

| You need | Why |
| - | - |
| An API key with the `campaigns`, `contacts` and `lists` [scopes](/concepts/scopes) | Leads, reply text and lists each sit on a different surface. |
| A campaign you own that has been sending for a while | Leads are read from your own campaigns only. A campaign delegated to you answers `404`. |
| A CRM connected in the app under **Settings → Integrations** | Needed to hand repliers over. See [CRM sync](/crm/overview). |

## Step by step

<Steps>
  <Step title="Find the campaign">
    `GET /business/campaigns` lists your campaigns with their headline numbers. Narrow it with `status` and `search`:

    ```bash theme={null}
    curl -G https://api.tryfuse.ai/api/v1/business/campaigns \
      -u "$FUSE_API_KEY:" \
      --data-urlencode "status=active" \
      --data-urlencode "search=Q4 fintech"
    ```

    ```json theme={null}
    {
      "campaigns": [
        {
          "id": "6a8da7718707dbcfb4448b96",
          "name": "Q4 fintech outreach",
          "status": "active",
          "type": "manual",
          "recipients": 412,
          "messagesSent": 980,
          "clicked": { "count": 37, "rate": 3.8 },
          "replied": { "count": 22, "rate": 2.2 }
        }
      ],
      "pagination": { "page": 1, "pageSize": 25, "total": 1, "totalPages": 1 }
    }
    ```
  </Step>

  <Step title="Sort the leads into repliers and clickers">
    Page through `GET /business/campaigns/{campaignId}/leads`, 100 at a time:

    ```bash theme={null}
    curl -G https://api.tryfuse.ai/api/v1/business/campaigns/6a8da7718707dbcfb4448b96/leads \
      -u "$FUSE_API_KEY:" \
      --data-urlencode "limit=100" \
      --data-urlencode "pageNum=1"
    ```

    ```json theme={null}
    {
      "leads": [
        {
          "id": "6a8da79aafc401ca94530c9d",
          "contactId": "6777d930c5c5e1865dc3f562",
          "name": "Ada Nwosu",
          "email": "ada@brightpay.com",
          "companyName": "Brightpay",
          "sentSteps": 3,
          "totalSteps": 3,
          "hasClicked": true,
          "hasReplied": false,
          "firstMessageReceivedAt": null
        }
      ],
      "pagination": { "page": 1, "pageSize": 100, "total": 412, "totalPages": 5 }
    }
    ```

    Use `contactId`, not `id`: `id` is the lead's row in this campaign, while `contactId` is the contact every other endpoint works with. Build two sets:

    * **Repliers:** `hasReplied` is `true`.
    * **Clickers to re-engage:** `hasClicked` is `true`, `hasReplied` is `false`, and `sentSteps` equals `totalSteps`, so they have finished the first sequence and will not get two campaigns at once.

    Skip opens. Some email clients load tracking pixels automatically, so `hasOpened` overstates interest.
  </Step>

  <Step title="Read what each replier said">
    `GET /business/contacts/{contactId}/activities` returns the contact's timeline, newest first. Replies are email entries with `status: "replied"` and the campaign's id:

    ```bash theme={null}
    curl "https://api.tryfuse.ai/api/v1/business/contacts/6777d930c5c5e1865dc3f562/activities?limit=20" \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "contactId": "6777d930c5c5e1865dc3f562",
      "activities": [
        {
          "channel": "email",
          "status": "replied",
          "campaignId": "6a8da7718707dbcfb4448b96",
          "subject": "Re: Month-end close at Brightpay",
          "body": { "contentType": "html", "content": "<p>Thanks, happy to chat on Thursday.</p>" },
          "receivedAt": "2026-10-01T14:02:11.000Z"
        }
      ],
      "nextCursor": null
    }
    ```

    Sort replies however your team works: by keyword, with your own model, or by hand. The timeline also shows calls and LinkedIn messages, so it answers "have we already spoken to this person?" before anyone follows up.
  </Step>

  <Step title="Hand repliers to your CRM">
    Put the repliers in their own list and push it:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists \
      -u "$FUSE_API_KEY:" -H "Content-Type: application/json" \
      -d '{ "name": "Q4 fintech - replied" }'

    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b3e0019c41d20014b42001/members \
      -u "$FUSE_API_KEY:" -H "Content-Type: application/json" \
      -d '{ "contactIds": ["6777d930c5c5e1865dc3f562"] }'

    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b3e0019c41d20014b42001/crm-push \
      -u "$FUSE_API_KEY:" -H "Content-Type: application/json" \
      -d '{ "crm": "hubspot" }'
    ```

    Adding members is idempotent, so a weekly run can add the same people again safely. To record how each person replied, add a `Reply status` [custom column](/guides/custom-columns) to the list and write your classification into it. Pushing the whole list creates any missing CRM records and only fills empty fields on existing ones. With **Push contact records** on in the app, Fuse's [activity sync](/crm/activity-sync) also logs the campaign emails against them.
  </Step>

  <Step title="Build the second-touch list">
    Create a list for the clickers and add them the same way:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists \
      -u "$FUSE_API_KEY:" -H "Content-Type: application/json" \
      -d '{ "name": "Q4 fintech - clicked, no reply" }'
    ```

    Then `POST /business/lists/{listId}/members` with their `contactId` values, up to 5,000 per call.
  </Step>

  <Step title="Duplicate the campaign and point it at the clickers">
    Duplicating keeps the sequence, schedule and sending setup you already tuned:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns/6a8da7718707dbcfb4448b96/duplicate \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    { "campaign": { "id": "6a8dce4da4566f4f2454bd07", "name": "Copy of Q4 fintech outreach" } }
    ```

    Point the copy at the clickers and clear the contacted-before exclusion:

    ```bash theme={null}
    curl -X PATCH https://api.tryfuse.ai/api/v1/business/campaigns/6a8dce4da4566f4f2454bd07 \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "campaignName": "Q4 fintech - second touch",
        "listIds": ["68b3e0559c41d20014b42011"],
        "excludeContacted": "none",
        "isDynamic": true
      }'
    ```

    <Warning>
      Everyone on this list was contacted by the first campaign. Leave an `excludeContacted` window on the copy and the campaign skips all of them.
    </Warning>

    `isDynamic: true` means clickers you add on later runs are enrolled automatically. The copy is ready to edit straight away, AI campaigns included, and keeps the original's exclusions except `excludeCompanyLists`. If the first campaign kept customers out with it, send it again in this `PATCH`.
  </Step>

  <Step title="Change the angle, then approve">
    The second touch should not repeat the first. On a manual campaign, rewrite each step with `PATCH /business/campaigns/{campaignId}/steps/{stepId}` and a new `subject` and `body`. On an AI campaign, `PATCH` a new `topic` onto each step, or rebuild them all with `POST /business/campaigns/{campaignId}/steps/regenerate-topics`.

    Preview each step with `POST /business/campaigns/{campaignId}/steps/{stepId}/generate`, then approve with `POST /business/campaigns/{campaignId}/approve`. See [Campaigns](/guides/campaigns#editing-steps) for the step fields.
  </Step>
</Steps>

## Run it every week

Re-run steps 2 to 5 weekly. Membership adds are idempotent, the dynamic second-touch campaign enrolls new clickers within about 30 minutes, and the CRM push only fills fields that are still empty.

To keep repliers out of everything you send next, create future campaigns with `excludeEngagedContacts: true`, and add the repliers list to `excludeLists`.

## What it costs

Reading leads and timelines, building lists, duplicating campaigns and pushing to your CRM cost no credits. Sending the second touch costs 10 credits per AI-written email and 5 per template email, except on the Free, Solo and Team plans, where sends are included. See [Credits & billing](/concepts/credits).

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| The second-touch campaign enrolls nobody | The copy kept an `excludeContacted` window, and every clicker was contacted by the first campaign. | `PATCH` the copy with `excludeContacted: "none"`. |
| The second touch reaches your customers | A duplicate does not copy `excludeCompanyLists`. | `PATCH` the copy with your customer list in `excludeCompanyLists` before approving. |
| `404 CAMPAIGN_NOT_FOUND` | The campaign is delegated or shared with you, not owned by you. | Run the workflow with the owner's API key. |
| `422 CRM_NOT_CONNECTED` | The team's CRM sync user has no live connection. | Reconnect the CRM in the app under **Settings → Integrations**. |
| The push answers `202` but nothing reaches the CRM | The CRM has no saved sync settings in the app, so the background push fails. | Open the CRM under **Settings → Integrations** and save its settings, then push again. |
| Someone appears in both campaigns at once | They had not finished the first sequence. | Only re-engage leads whose `sentSteps` equals `totalSteps`. |

## Run the whole workflow

<Accordion title="Python script: weekly reply hand-off and second touch">
  ```python theme={null}
  """Hand campaign repliers to the CRM and re-engage clickers with a second-touch campaign."""
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")
  CAMPAIGN_ID = os.environ["FUSE_CAMPAIGN_ID"]
  SECOND_TOUCH_ID = os.environ.get("FUSE_SECOND_TOUCH_ID")  # set after the first run
  CRM = "hubspot"


  class FuseError(Exception):
      def __init__(self, status, code, message):
          super().__init__(f"{status} {code}: {message}")
          self.code = code


  def call(method, path, **kwargs):
      """Send one request, wait out 429s, and raise FuseError on any other failure."""
      while True:
          response = requests.request(method, BASE + path, auth=AUTH, timeout=60, **kwargs)
          if response.status_code == 429:
              time.sleep(int(response.headers.get("Retry-After", 5)))
              continue
          if response.status_code >= 400:
              try:
                  error = response.json().get("error", {})
              except ValueError:
                  error = {}
              raise FuseError(response.status_code, error.get("code"), error.get("message"))
          return response.json() if response.content else None


  def list_named(name):
      """Return the id of the contact list with this name, creating it if needed."""
      found = call("GET", "/lists", params={"search": name, "entityType": "contactList"})["data"]
      for existing in found:
          if existing["name"] == name:
              return existing["id"]
      return call("POST", "/lists", json={"name": name})["list"]["id"]


  def add_members(list_id, contact_ids):
      for start in range(0, len(contact_ids), 5000):
          call("POST", f"/lists/{list_id}/members", json={"contactIds": contact_ids[start:start + 5000]})


  # 2. Sort the leads into repliers and finished clickers
  repliers, clickers, page = [], [], 1
  while True:
      body = call("GET", f"/campaigns/{CAMPAIGN_ID}/leads", params={"limit": 100, "pageNum": page})
      for lead in body["leads"]:
          if lead["hasReplied"]:
              repliers.append(lead["contactId"])
          elif lead["hasClicked"] and lead["sentSteps"] == lead["totalSteps"]:
              clickers.append(lead["contactId"])
      if page >= body["pagination"]["totalPages"]:
          break
      page += 1
  print(f"{len(repliers)} repliers, {len(clickers)} clickers who finished without replying")

  # 3. Print what each replier said
  for contact_id in repliers:
      timeline = call("GET", f"/contacts/{contact_id}/activities", params={"limit": 20})["activities"]
      replies = [a for a in timeline if a["status"] == "replied" and a["campaignId"] == CAMPAIGN_ID]
      if replies:
          print(f"\n{contact_id}: {replies[0]['subject']}\n{replies[0]['body']['content']}")

  # 4. Hand repliers to the CRM
  if repliers:
      replied_list = list_named("Q4 fintech - replied")
      add_members(replied_list, repliers)
      if CRM in call("GET", "/integrations")["integrations"]["connected"]:
          call("POST", f"/lists/{replied_list}/crm-push", json={"crm": CRM})

  # 5-6. Put the clickers on their own list, then create the second touch once
  if clickers:
      clicked_list = list_named("Q4 fintech - clicked, no reply")
      add_members(clicked_list, clickers)
      if not SECOND_TOUCH_ID:
          # The copy is ready at once and keeps every exclusion except excludeCompanyLists
          copy_id = call("POST", f"/campaigns/{CAMPAIGN_ID}/duplicate")["campaign"]["id"]
          call("PATCH", f"/campaigns/{copy_id}", json={
              "campaignName": "Q4 fintech - second touch",
              "listIds": [clicked_list],
              "excludeContacted": "none",
              "isDynamic": True,
          })
          print(f"Draft second touch {copy_id}: rewrite its steps, preview, then approve.")
          print(f"Set FUSE_SECOND_TOUCH_ID={copy_id} so later runs only add members.")
  ```
</Accordion>

## Do it from Claude

With the [Fuse MCP server](/mcp/connect) connected:

> Who replied to my "Q4 fintech outreach" campaign? Summarize each reply and push those people to HubSpot. Then draft a second-touch campaign for the people who clicked but never replied, with a different angle from the first one, and don't launch it.

The assistant reads the campaign's replies, summarizes them, pushes those people to HubSpot and drafts the new campaign. The push writes to your CRM as soon as it runs, so keep your client's approval prompt on for it.

## Related

<CardGroup cols={2}>
  <Card title="Campaigns" icon="paper-plane" href="/guides/campaigns">
    Leads, stats, duplicating and exclusions.
  </Card>

  <Card title="CRM sync" icon="arrows-rotate" href="/crm/overview">
    What Fuse writes to each CRM, including engagement fields.
  </Card>

  <Card title="Review what a campaign will send" icon="list-check" href="/guides/approve-scheduled-sends">
    Approve, edit or skip queued messages before they go out.
  </Card>

  <Card title="Personalize every email with AI research" icon="pen-nib" href="/workflows/personalize-emails-with-ai-research">
    Give the second touch a researched opening line.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.