> ## 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.

# Qualify leads with AI before you enrich them

> Score a raw prospect list against your ICP with an AI smart column, then spend enrichment credits only on the leads that qualify. A step-by-step API workflow.

Saving a prospect costs 2 credits, while finding their work email costs 20 to 50. This workflow saves a broad list without enrichment, has an AI [smart column](/guides/smart-columns) judge every row against your ideal customer profile (ICP), and then enriches and contacts only the rows that pass. If a third of a 2,000-row list qualifies, you pay for email enrichment on about 670 contacts instead of 2,000, and your campaign reaches only people who fit.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[Save search] -->|listId| B[ICP smart column]
  B -->|yes filter| C[Copy qualified]
  C -->|listId| D[Enrich emails]
  D --> E[Campaign]
```

## Before you start

| You need | Why |
| - | - |
| An API key with the `search` and `lists` [scopes](/concepts/scopes), plus `campaigns` to launch | Saving searches, running smart columns and enriching sit on those surfaces. |
| A qualifying question the web can answer | "Does this company run its own delivery fleet?" works. "Would they buy from us?" does not. |
| Credits | 2 per saved row, the smart column's `creditsPerRow` (20 for `standard`) for every row it answers, and 20 to 50 per work email found for the qualified contacts. |

## Step by step

<Steps>
  <Step title="Save a broad list without enrichment">
    Cast a wide net with filters you are confident in, and save the matches without enrichment at 2 credits a row.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search/save-to-list \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "filters": {
          "job_title_role": [{ "status": "include", "value": "operations" }],
          "job_title_levels": [{ "status": "include", "value": "director" }, { "status": "include", "value": "vp" }],
          "industry": [{ "status": "include", "value": "logistics and supply chain" }],
          "location_country": [{ "status": "include", "value": "united states" }]
        },
        "listName": "Logistics ops leaders - raw",
        "limit": 2000,
        "enrich": "none"
      }'
    ```

    ```json theme={null}
    { "listIds": ["68b0a1c29c41d20014b40101"], "enrich": "none" }
    ```

    Poll `GET /business/lists/{listId}` until `completionStatus` is `complete`. Filter values must come from the accepted vocabulary, see [Build a list from search](/guides/build-a-list-from-search#look-up-the-accepted-filter-values).
  </Step>

  <Step title="Add an ICP smart column and test it on a sample">
    Ask one question about each row and run it on the first 100 rows only.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b0a1c29c41d20014b40101/smart-columns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "ICP fit",
        "prompt": "Does this person'\''s company run its own fleet of delivery vehicles? Check the company website and recent news. Answer with exactly one word: yes or no.",
        "engine": "standard",
        "runOn": "first_100"
      }'
    ```

    ```json theme={null}
    {
      "jobId": "68b0a2109c41d20014b40155",
      "column": { "id": "68b0a2109c41d20014b40150", "name": "ICP fit", "engine": "standard" }
    }
    ```

    Keep both ids. The prompt does three things that matter here: it asks one question, it names the subject ("this person's company"), and it demands a one-word answer. Row filters match text anywhere in a cell and ignore case, so one-word answers keep a "no" row from ever matching "yes".
  </Step>

  <Step title="Check the sample before you scale">
    Poll the run until its `status` is `completed` or `partially_completed`:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/lists/68b0a1c29c41d20014b40101/smart-columns/68b0a2109c41d20014b40155/status \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "job": {
        "status": "completed",
        "mode": "initial",
        "totalRows": 100,
        "successfulRows": 98,
        "failedRows": 2,
        "creditsUsed": 1960,
        "creditsPerRow": 20
      }
    }
    ```

    Then read a few rows and look at each answer under `columnValues`, keyed by the column id:

    ```bash theme={null}
    curl "https://api.tryfuse.ai/api/v1/business/lists/68b0a1c29c41d20014b40101/rows?limit=20" -u "$FUSE_API_KEY:"
    ```

    If the answers are wrong or wordy, fix the prompt now with `PATCH /business/lists/{listId}/smart-columns/{columnId}`. A prompt edit re-answers every row the column has already answered, so it is cheapest while that is only the sample.
  </Step>

  <Step title="Answer the rest of the list">
    Check the price first. `GET /business/lists/{listId}/smart-columns/{columnId}` returns the column's `creditsPerRow`. Multiply it by the rows still unanswered.

    Then re-run with `new_only`, which answers only rows that have never been answered, so the 100 sample rows are not billed twice:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b0a1c29c41d20014b40101/smart-columns/68b0a2109c41d20014b40150/rerun \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "mode": "new_only" }'
    ```

    ```json theme={null}
    { "jobId": "68b0a2809c41d20014b40188", "columnId": "68b0a2109c41d20014b40150", "mode": "new_only" }
    ```

    Poll the new `jobId` the same way. The status endpoint reports `creditsUsed` while the run is still going.
  </Step>

  <Step title="Count the rows that qualify">
    Filter the rows on the column's answer. `filters` is URL-encoded JSON, and `customColumns` is keyed by column id, not name:

    ```bash theme={null}
    curl -G https://api.tryfuse.ai/api/v1/business/lists/68b0a1c29c41d20014b40101/rows \
      -u "$FUSE_API_KEY:" \
      --data-urlencode 'filters={"customColumns":{"68b0a2109c41d20014b40150":{"dataType":"string","include":["yes"]}}}' \
      --data-urlencode "limit=1"
    ```

    ```json theme={null}
    { "entityType": "contactList", "data": ["…"], "pagination": { "pageNum": 1, "totalPages": 671, "totalRecords": 671 } }
    ```

    `pagination.totalRecords` is the number of qualified rows.
  </Step>

  <Step title="Copy the qualified rows into their own list">
    `POST /business/lists/save-to-list` takes the same filter and copies only the matching rows:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/save-to-list \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "sourceListId": "68b0a1c29c41d20014b40101",
        "listName": "Logistics ops leaders - qualified",
        "filters": { "customColumns": { "68b0a2109c41d20014b40150": { "dataType": "string", "include": ["yes"] } } },
        "limit": 671
      }'
    ```

    ```json theme={null}
    { "listIds": ["68b0a3009c41d20014b401a0"] }
    ```

    Poll the new list until it is `complete`. The copy adds the same contacts rather than creating new ones, so nothing is duplicated. The "ICP fit" answers stay on the raw list.
  </Step>

  <Step title="Enrich only the qualified contacts">
    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b0a3009c41d20014b401a0/enrich \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "mode": "email", "limit": 671 }'
    ```

    ```json theme={null}
    { "listIds": ["68b0a3009c41d20014b401a0"], "mode": "email", "totalContacts": 671, "estimatedMinutes": 34, "jobId": "68b0a3409c41d20014b401b5", "status": "pending" }
    ```

    Poll `GET /business/jobs/{jobId}` with the `jobId` from the `202` until `status` is `completed`, then read `metrics.totalWithProfessionalEmail` on `GET /business/lists/{listId}`. Do not wait on the list's `completionStatus` here: it stays `complete` until the job starts.

    <Tip>
      Prefer to keep one list? Skip the copy and send the same `filters` straight to `POST /business/lists/{listId}/enrich` on the raw list. Enrichment then runs only on the rows that match.
    </Tip>
  </Step>

  <Step title="Launch outreach on the qualified list">
    Create a campaign on the qualified list as in steps 5 to 9 of [Build an AI outbound campaign from a prompt](/workflows/ai-campaign-from-a-prompt). Set `isDynamic: true` if you will keep adding qualified rows, so the campaign enrolls them as they arrive.
  </Step>
</Steps>

## Keep qualifying as the list grows

Saving into the raw list again with the same `listName` appends to it. Each time you do, repeat three calls:

1. `POST .../smart-columns/{columnId}/rerun` with `mode: "new_only"` answers only the new rows.
2. `POST /business/lists/save-to-list` into the qualified list (`listId`), adding `"otherLists": { "exclude": ["Logistics ops leaders - qualified"] }` to the filter so only newly qualified rows are copied.
3. `POST /business/lists/{listId}/enrich` on the qualified list. Contacts that already have an email are not charged again.

A dynamic campaign on the qualified list enrolls the new contacts within about 30 minutes.

## What it costs

For a 2,000-row list where 671 rows qualify:

| Step | Credits |
| - | - |
| Save 2,000 rows with `enrich: "none"` | 4,000 |
| Answer 2,000 rows with a `standard` smart column | 40,000 at 20 a row, less any rows that fail |
| Find emails for the 671 qualified contacts | 20 to 50 per email found, so at most 33,550 instead of at most 100,000 for all 2,000 |
| Copy, count and read rows | Free |

Qualifying saves credits when the column's `creditsPerRow` is lower than the enrichment you avoid on the rows that fail. A `standard` column costs 20 a row, the same as a work email from Fuse's own data, so the saving is largest when emails come from partner providers (50) or you also want phones (200). Either way, the campaign reaches only people who fit. Use `standard` rather than `deep_research` (100 a row) for yes or no questions about public facts.

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| Answers are sentences, not one word | The prompt did not constrain the answer. | Edit the prompt while the column has only answered the sample. |
| The filter returns no rows | The filter names the column by name instead of id, or no answer contains "yes". | Read `columnValues` on a few rows and check the column id with `GET /business/lists/{listId}?include=columns`. |
| `422 INVALID_ROW_FILTERS` | The filter has an unknown key or the wrong shape. | `customColumns` maps a column id to `{ "dataType": "string", "include": [...] }`. |
| `402 INSUFFICIENT_CREDITS` on a smart column run | The balance must cover every row the run will answer before it starts. | Top up, or run on fewer rows with `runOn`. |
| `422 NO_ROWS_TO_RESEARCH` on a re-run | Every row already has an answer. | Nothing to do. |
| `409 ALL_CONTACTS_ALREADY_ENRICHED` | Every qualified contact already has an email. | Nothing to do, and nothing was billed. |

## Run the whole workflow

<Accordion title="Python script: qualify, then enrich">
  ```python theme={null}
  """Qualify a raw prospect list with an AI smart column, then enrich only the leads that fit."""
  import json
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")
  FINISHED_RUNS = ("completed", "partially_completed", "failed", "cancelled")


  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 wait_until(read, done, delay=10, max_delay=60):
      """Poll read() until done(result) is true, waiting before each poll and backing off."""
      while True:
          time.sleep(delay)
          result = read()
          if done(result):
              return result
          delay = min(delay * 1.5, max_delay)


  def wait_for_list(list_id):
      return wait_until(
          lambda: call("GET", f"/lists/{list_id}")["list"],
          lambda current: current["completionStatus"] == "complete",
      )


  def wait_for_enrichment(job_id):
      """Poll an enrichment job until it ends. A stopped job waits for a top-up and a resume in the app."""
      return wait_until(
          lambda: call("GET", f"/jobs/{job_id}")["job"],
          lambda job: job["status"] in ("completed", "cancelled", "failed", "stopped"),
      )


  def wait_for_run(list_id, job_id):
      job = wait_until(
          lambda: call("GET", f"/lists/{list_id}/smart-columns/{job_id}/status")["job"],
          lambda current: current["status"] in FINISHED_RUNS,
      )
      print(f"Run {job['status']}: {job['successfulRows']} answered, "
            f"{job['failedRows']} failed, {job['creditsUsed']} credits")
      return job


  # 1. Save a broad list without enrichment
  raw_id = call("POST", "/prospects/search/save-to-list", json={
      "filters": {
          "job_title_role": [{"status": "include", "value": "operations"}],
          "job_title_levels": [{"status": "include", "value": "director"},
                               {"status": "include", "value": "vp"}],
          "industry": [{"status": "include", "value": "logistics and supply chain"}],
          "location_country": [{"status": "include", "value": "united states"}],
      },
      "listName": "Logistics ops leaders - raw",
      "limit": 2000,
      "enrich": "none",
  })["listIds"][0]
  raw = wait_for_list(raw_id)

  # 2-3. Test the ICP question on the first 100 rows
  created = call("POST", f"/lists/{raw_id}/smart-columns", json={
      "name": "ICP fit",
      "prompt": "Does this person's company run its own fleet of delivery vehicles? Check the company "
                "website and recent news. Answer with exactly one word: yes or no.",
      "engine": "standard",
      "runOn": "first_100",
  })
  column_id = created["column"]["id"]
  wait_for_run(raw_id, created["jobId"])
  for row in call("GET", f"/lists/{raw_id}/rows", params={"limit": 10})["data"]:
      print(row.get("companyName"), "->", row["columnValues"].get(column_id))
  if input("Do these answers look right? Answer the rest of the list [y/N] ").strip().lower() != "y":
      raise SystemExit(f"Edit the prompt with PATCH /lists/{raw_id}/smart-columns/{column_id}, then continue.")

  # 4. Answer every row the sample skipped
  price = call("GET", f"/lists/{raw_id}/smart-columns/{column_id}")["column"]["creditsPerRow"]
  print(f"About {price * (raw['totalRecords'] - 100)} credits to answer the remaining rows")
  rerun = call("POST", f"/lists/{raw_id}/smart-columns/{column_id}/rerun", json={"mode": "new_only"})
  wait_for_run(raw_id, rerun["jobId"])

  # 5. Count the rows that qualify
  qualified = {"customColumns": {column_id: {"dataType": "string", "include": ["yes"]}}}
  qualified_count = call("GET", f"/lists/{raw_id}/rows", params={
      "filters": json.dumps(qualified),
      "limit": 1,
  })["pagination"]["totalRecords"]
  print(f"{qualified_count} of {raw['totalRecords']} rows qualify")
  if qualified_count == 0:
      raise SystemExit("Nothing qualified. Loosen the question or the search.")

  # 6. Copy the qualified rows into their own list
  qualified_id = call("POST", "/lists/save-to-list", json={
      "sourceListId": raw_id,
      "listName": "Logistics ops leaders - qualified",
      "filters": qualified,
      "limit": qualified_count,
  })["listIds"][0]
  wait_for_list(qualified_id)

  # 7. Enrich only the qualified contacts
  started = call("POST", f"/lists/{qualified_id}/enrich", json={"mode": "email", "limit": qualified_count})
  if started.get("jobId"):
      print("Enrichment", wait_for_enrichment(started["jobId"])["status"])
  enriched = call("GET", f"/lists/{qualified_id}")["list"]
  print("Qualified contacts with a work email:", enriched["metrics"]["totalWithProfessionalEmail"])
  ```
</Accordion>

## Do it from Claude

With the [Fuse MCP server](/mcp/connect) connected, work through it in two requests so you can check the sample first:

> In my list "Logistics ops leaders - raw", add a smart column called "ICP fit" that answers "Does this person's company run its own fleet of delivery vehicles?" with exactly one word, yes or no. Run it on the first 100 rows and show me ten answers.

> Run "ICP fit" on the rest of the list, copy every row that says yes into a list called "Logistics ops leaders - qualified", and find work emails for that list.

The assistant uses the smart column, list and enrichment tools.

## Related

<CardGroup cols={2}>
  <Card title="Smart columns" icon="sparkles" href="/guides/smart-columns">
    Engines, prompts, re-runs, provenance and spend controls.
  </Card>

  <Card title="Working with lists" icon="list" href="/guides/working-with-lists">
    Enrichment modes, copying rows and the row filter.
  </Card>

  <Card title="Credits & billing" icon="coins" href="/concepts/credits">
    What every operation costs.
  </Card>

  <Card title="AI campaign from a prompt" icon="wand-magic-sparkles" href="/workflows/ai-campaign-from-a-prompt">
    Turn the qualified list into an approved AI campaign.
  </Card>
</CardGroup>


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