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

# Build a list from search

> Turn a prospect search into a saved, enriched list: check filter values, preview cheaply, save once, and poll until the rows land.

This guide walks the full loop from an idea ("US fintech CFOs") to a populated list you can [enrich](/guides/custom-columns), [export](/guides/export-a-list), or [push into a campaign](/guides/campaigns). The flow is deliberately split into a cheap preview step and a billed save step, so you tune filters before you commit credits.

<Info>
  You need an API key with the `search` scope for every endpoint on this page, plus the `lists` scope for the final polling step — see [authentication](/authentication) and [scopes](/concepts/scopes). Searching and saving both debit search credits; see [credits](/concepts/credits).
</Info>

<Steps>
  <Step title="Look up the accepted filter values">
    Enumerated filters (industries, seniority levels, locations, company sizes, and so on) only accept **values from a fixed vocabulary**. A value outside it is refused with `422 VALIDATION_FAILED`, naming the field and the value, before anything is billed. Matching ignores case. So before you write filters by hand, fetch the vocabulary:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/prospects/filter-options \
      -u "af_YOUR_KEY:"
    ```

    ```json theme={null}
    {
      "filterOptions": {
        "fields": {
          "job_title_levels": {
            "valueShape": "include_exclude",
            "values": [
              { "label": "CXO", "value": "cxo" },
              { "label": "Director", "value": "director" },
              { "label": "VP", "value": "vp" }
            ]
          },
          "industry": {
            "valueShape": "include_exclude",
            "values": [{ "label": "Computer Software", "value": "computer software" }]
          }
        },
        "jobTitleRoleToSubRole": { "finance": ["accounting", "investment_banking"] }
      }
    }
    ```

    Copy the `value` of each entry, not its `label`. Free-text filters are not checked this way, so if a search that should obviously match returns nothing, look at those next.

    Free-text fields (locations, titles, skills, schools, company names, websites) have no fixed list to enumerate — for those, `GET /business/prospects/autocomplete` suggests fully-qualified values from the same vocabulary the Fuse app's type-ahead uses:

    ```bash theme={null}
    curl "https://api.tryfuse.ai/api/v1/business/prospects/autocomplete?field=location&q=san%20fran" \
      -u "af_YOUR_KEY:"
    ```

    ```json theme={null}
    {
      "field": "location",
      "suggestions": [
        { "name": "san francisco, california, united states" }
      ]
    }
    ```

    `field` is one of `location`, `region`, `title`, `skill`, `school`, `company`, `website`. A partial string like `"San Fran"` matches nothing in a search; the suggestion's fully-qualified form does. `region` suggestions additionally carry a `countries` array (likeliest first), because the same region name can exist in several countries — pair the region with a `location_country` filter to disambiguate, like the app does.
  </Step>

  <Step title="Preview the search">
    `POST /business/prospects/search` runs the search and returns one page of matching profiles. Enumerated filters take an array of `{ "status": "include" | "exclude", "value": "..." }` objects — `exclude` flips a value into an exclusion. `size` controls the page size (1–100, default 10).

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search \
      -u "af_YOUR_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "filters": {
          "job_title_levels": [{ "status": "include", "value": "cxo" }],
          "industry": [{ "status": "include", "value": "financial services" }],
          "location_country": [{ "status": "include", "value": "united states" }]
        },
        "size": 10
      }'
    ```

    ```json theme={null}
    {
      "profiles": [
        {
          "full_name": "ada nwosu",
          "first_name": "ada",
          "last_name": "nwosu",
          "job_title": "chief financial officer",
          "job_title_levels": ["cxo"],
          "job_company_name": "brightpay",
          "job_company_website": "brightpay.com",
          "industry": "financial services",
          "location_name": "san francisco, california, united states",
          "linkedin_url": "linkedin.com/in/ada-nwosu",
          "savedProfile": false,
          "savedBy": null
        }
      ],
      "scrollToken": "eyJwYWdlIjoyfQ",
      "total": 4805968,
      "filters": {
        "job_title_levels": [{ "status": "include", "value": "cxo" }],
        "industry": [{ "status": "include", "value": "financial services" }],
        "location_country": [{ "status": "include", "value": "united states" }]
      }
    }
    ```

    Profiles use the search index's field names (`first_name`, `job_title`, `job_company_name`, `linkedin_url`, and so on), with string values normalized to lowercase, plus `savedProfile`/`savedBy` telling you whether someone on your team already saved that person. Emails and phones are never present here — they arrive when you save to a list and enrichment runs.

    `total` is the true match count for the whole query (not the page size), and `filters` echoes the filter set that actually ran. With literal filters the echo is what you sent; with the alternative sources below it is what your input **resolved to** — inspect it, refine it, and send it back as `filters` on the next call or on [save-to-list](#save-the-results-into-a-list).

    <Accordion title="Three ways to express the search">
      The body carries **exactly one** filter source; combining two returns a `422`.

      **Literal `filters`** — the structured form shown above.

      **A saved search** — `savedSearchId`, or `savedSearchName` for an exact title match. Names resolve strictly: no match is a `404`, and two searches sharing the title is a `409 SAVED_SEARCH_NAME_AMBIGUOUS` — the API never silently picks one. The response additionally carries `ignoredFilterKeys` (see [Saved searches](#saved-searches)).

      **A natural-language `query`** — the same smart search as the app's prompt box. The prose is converted into structured filters server-side, the search runs on those, and the response echoes them plus `unresolvedCriteria`: the parts of your prose that could not be mapped to any filter, quoted verbatim. A non-empty array means the results are broader than your sentence — tighten by editing the echoed `filters` directly.

      ```bash theme={null}
      curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search \
        -u "af_YOUR_KEY:" \
        -H "Content-Type: application/json" \
        -d '{ "query": "VPs of engineering at mid-size software companies in Germany", "size": 10 }'
      ```

      ```json theme={null}
      {
        "profiles": ["…"],
        "scrollToken": "…",
        "total": 19,
        "filters": {
          "job_title_role": [{ "status": "include", "value": "engineering" }],
          "job_company_size": [
            { "status": "include", "value": "201-500" },
            { "status": "include", "value": "501-1000" }
          ],
          "location_country": [{ "status": "include", "value": "germany" }]
        },
        "unresolvedCriteria": []
      }
      ```

      A `query` too vague to yield any filter returns `422 QUERY_FILTERS_INVALID` rather than running (and billing) an unconstrained search. Query conversion also has its own tighter rate limit on top of the search one, since it runs a model per call.
    </Accordion>

    Treat search results as preview data: responses may include additional undocumented fields, and those can change without notice — build only on the fields shown here. Search profiles do not carry a stable contact identifier; you get one when a person enters your workspace — the `id` on [list rows](/concepts/pagination) after a save, or via [`POST /business/contacts/resolve`](/guides/upload-your-contacts) from a LinkedIn URL.

    To fetch the next page, repeat the request with the same `filters` and the `scrollToken` from the previous response in the body. See [pagination](/concepts/pagination) for how scroll tokens behave.

    Each page you fetch debits search credits, so keep `size` small while you tune filters. Previewing is the cheap way to tune filters before you commit to saving.

    <Accordion title="Filter shapes and the full key list">
      The `filters` object accepts three shapes of field, plus a `rangeInputs` object for numeric ranges:

      | Shape | How to pass it | Filter keys |
      | - | - | - |
      | Include/exclude array | `[{ "status": "include", "value": "..." }]`; values must come from `filter-options` | `industry`, `job_title_levels`, `job_title_role`, `job_title_sub_role`, `job_company_size`, `job_company_inferred_revenue`, `job_company_industry`, `job_company_location_country`, `job_company_location_region`, `job_company_location_continent`, `location_continent`, `location_country`, `location_region`, `languages.name`, `education.degrees`, `experience.company.type`, `experience.title.levels`, `experience.title.role`, `experience.title.sub_role`, `experience.company.location.country`, `experience.company.location.continent`, `experience.company.industry` |
      | String or array | A bare string, or an array mixing strings and include/exclude objects | `job_company_name`, `job_company_website`, `linkedin_url` |
      | Free text | A string or an array of strings (`keywords` and `excludedKeywords` are array-only) | `full_name`, `first_name`, `last_name`, `sex`, `work_email`, `mobile_phone`, `personal_emails`, `job_title`, `job_company_location_name`, `location_name`, `github_url`, `github_username`, `facebook_url`, `education.school.name`, `experience.title.name`, `experience.company.name`, `experience.company.website`, `experience.company.location.name`, `job_start_date`, `skills`, `certifications.name`, `keywords`, `excludedKeywords` |

      `rangeInputs` sits alongside the other filters and takes `{ "min": number, "max": number }` objects for `job_company_employee_count`, `inferred_years_experience`, and `job_company_total_funding_raised`:

      ```json theme={null}
      {
        "filters": {
          "rangeInputs": {
            "job_company_employee_count": { "min": 50, "max": 500 }
          }
        }
      }
      ```
    </Accordion>
  </Step>

  <Step title="Save the results into a list">
    When the preview looks right, save the matches with `POST /business/prospects/search/save-to-list`. The body takes the same filter source as the search — `filters`, `savedSearchId`/`savedSearchName`, or `query` (exactly one) — plus a `limit` for how many rows to save (1–10,000, required) and a destination: `listId` for an existing list, or `listName`, which **creates the list or reuses the existing one with that name**, ignoring case (a name shared by two lists is a `409 LIST_NAME_AMBIGUOUS`, so use `listId` instead; a name reserved for the app's own lists is a `422 LIST_NAME_RESERVED`).

    `enrich` selects what runs on the saved rows:

    | Mode | What lands in the list |
    | - | - |
    | `none` (default) | The search rows as-is — names, titles, companies, LinkedIn URLs. |
    | `email` | Rows plus discovered email addresses (the enrichment waterfall). |
    | `phone` | Rows plus discovered phone numbers. |
    | `phone_and_email` | Both. |

    With enrichment, saving the rows adds no charge of its own: the run bills for the values it finds, at the [enrichment prices](/concepts/credits), and contacts that already carry the requested data are not re-charged. They still count toward `limit`, so saving the same search again takes the same people.

    An optional `campaignId` **or** `campaignName` (strict resolve — `404` on a miss, `409 CAMPAIGN_NAME_AMBIGUOUS` on a shared name) attaches the destination list to that campaign in the same call.

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search/save-to-list \
      -u "af_YOUR_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "filters": {
          "job_title_levels": [{ "status": "include", "value": "cxo" }],
          "industry": [{ "status": "include", "value": "financial services" }],
          "location_country": [{ "status": "include", "value": "united states" }]
        },
        "listName": "US fintech CFOs",
        "limit": 500,
        "enrich": "phone_and_email"
      }'
    ```

    ```json theme={null}
    {
      "listIds": ["68a1f20b9c41d20014b3e901"],
      "enrich": "phone_and_email",
      "totalContacts": 500,
      "estimatedMinutes": 25,
      "jobId": "68a2109c9c41d20014b41003",
      "status": "pending"
    }
    ```

    The response is a `202`: the job has started, not finished. The search runs server-side, and contacts appear in the list as saving (and enrichment, when requested) completes. Enriched saves also report `totalContacts`, `estimatedMinutes`, and the enrichment run's `jobId` and `status` (`pending` until the job starts, which waits for any other enrichment job of yours to finish, then `processing`); a campaign attach reports back as `"campaign": { "id": "…", "attached": true }`.

    Note that preview and save use different sizing fields on purpose: `size` is a page size for browsing results, while `limit` is the total number of rows the save job will pull and enrich.

    <Warning>
      This endpoint runs its own search — it does not reuse results from a previous `POST /prospects/search`. Passing the same filters to both endpoints pays for the search twice. Preview with a small `size` to tune filters, then save once. If cost matters most, skip the preview entirely and call save-to-list directly. Hand-picking a subset of previewed profiles to save is not supported; narrow `filters` instead. See [credits](/concepts/credits).
    </Warning>

    <Note>
      Campaign-attach timing: the attach happens when the save **starts**. A draft campaign enrolls the full list when you approve it, but an ACTIVE non-dynamic campaign enrolls only the rows present at attach time — approve after the list fills, or use a dynamic campaign, to reach everything saved.
    </Note>
  </Step>

  <Step title="Poll the list until it fills">
    With `enrich: "none"`, poll the list itself until its `completionStatus` is `complete`. Row counts climb while the job runs, so a list that already has rows is not necessarily finished.

    With `enrich` set, follow the run at `GET /business/jobs/{jobId}` until its `status` is `completed`, `stopped`, `failed` or `cancelled`. Do not wait on the list here: it reads `complete` until the job starts, so it can look finished before any rows land. The job reports its counts and credits so far, and a `stopped` job paused because the balance ran out. See [long-running work](/concepts/long-running-work#following-an-enrichment-job).

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901 \
      -u "af_YOUR_KEY:"
    ```

    ```json theme={null}
    {
      "list": {
        "id": "68a1f20b9c41d20014b3e901",
        "name": "US fintech CFOs",
        "entityType": "contactList",
        "completionStatus": "complete",
        "totalRecords": 412,
        "metrics": { "totalEnriched": 400, "totalWithProfessionalEmail": 350, "totalWithValidPhone": 120 }
      }
    }
    ```

    `metrics` counts the rows that came back with a work email, a personal email or a valid phone. Once the list is complete, read its rows:

    ```bash theme={null}
    curl "https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901/rows?limit=100&pageNum=1" \
      -u "af_YOUR_KEY:"
    ```

    ```json theme={null}
    {
      "data": [
        {
          "id": "68a1f2c89c41d20014b3e955",
          "firstName": "Ada",
          "lastName": "Nwosu",
          "linkedinUrl": "https://www.linkedin.com/in/ada-nwosu",
          "jobTitle": "Chief Financial Officer",
          "companyName": "Brightpay",
          "companyDomain": "brightpay.com",
          "columnValues": {
            "68a1f3009c41d20014b3e9a1": "warm"
          }
        }
      ],
      "pagination": {
        "pageNum": 1,
        "totalPages": 5,
        "totalRecords": 412
      }
    }
    ```

    This endpoint uses page-number pagination (`pageNum` and `limit`, max 1000 per page) rather than scroll tokens — see [pagination](/concepts/pagination). A wrong or inaccessible `listId` returns a `404` with the standard [error envelope](/concepts/errors):

    ```json theme={null}
    {
      "error": {
        "code": "LIST_NOT_FOUND",
        "message": "The requested list was not found.",
        "param": null,
        "doc_url": "https://docs.fuseai.com/concepts/errors#list_not_found",
        "request_id": "req_8fK2mQ"
      }
    }
    ```
  </Step>
</Steps>

## Saved searches

Saved searches let you rerun a query you have already tuned, without carrying the filter JSON around. They are shared with the Fuse app in both directions: a search saved in the app is callable from the API, and a search created through the API opens identically in the app's filter rail.

`GET /business/saved-searches` lists the searches saved on your account, people and company searches together. Each one's `searchType` says which, and its filters are reported in that search's own vocabulary: people searches in the keys `POST /prospects/search` accepts, company searches in the keys of `POST /companies/search`. Add `?type=people` or `?type=company` to list only one kind.

```bash theme={null}
curl https://api.tryfuse.ai/api/v1/business/saved-searches \
  -u "af_YOUR_KEY:"
```

```json theme={null}
{
  "savedSearches": [
    {
      "id": "68a1f7409c41d20014b3ed31",
      "title": "US fintech CFOs",
      "filters": {
        "job_title_levels": [{ "status": "include", "value": "cxo" }],
        "industry": [{ "status": "include", "value": "financial services" }],
        "location_country": [{ "status": "include", "value": "united states" }]
      },
      "ignoredFilterKeys": [],
      "searchType": "people",
      "createdAt": "2026-06-14T08:30:00.000Z",
      "updatedAt": "2026-06-14T08:30:00.000Z"
    }
  ]
}
```

### Create, update, delete

`POST /business/saved-searches` stores a filter set under a title — the same `filters` object the search endpoint takes:

```bash theme={null}
curl -X POST https://api.tryfuse.ai/api/v1/business/saved-searches \
  -u "af_YOUR_KEY:" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "US fintech CFOs",
    "filters": {
      "job_title_levels": [{ "status": "include", "value": "cxo" }],
      "industry": [{ "status": "include", "value": "financial services" }],
      "location_country": [{ "status": "include", "value": "united states" }]
    }
  }'
```

The `201` returns the stored search in the list-row shape above. A filter field that cannot round-trip through the app's filter rail is rejected with `422 SAVED_SEARCH_FILTERS_UNSUPPORTED` (`param` names the field) rather than stored unusable — run such filters as a direct search instead.

`GET /business/saved-searches/{savedSearchId}` reads one. `PATCH` renames it and/or replaces its filters (send `title`, `filters`, or both). `DELETE` removes it and returns a `204`:

```bash theme={null}
curl -X PATCH https://api.tryfuse.ai/api/v1/business/saved-searches/68a1f7409c41d20014b3ed31 \
  -u "af_YOUR_KEY:" \
  -H "Content-Type: application/json" \
  -d '{ "title": "US + UK fintech CFOs" }'

curl -X DELETE https://api.tryfuse.ai/api/v1/business/saved-searches/68a1f7409c41d20014b3ed31 \
  -u "af_YOUR_KEY:"
```

### Run one

The primary way is `POST /business/prospects/search` with `savedSearchId` (or `savedSearchName` — strict resolve, `409 SAVED_SEARCH_NAME_AMBIGUOUS` when two share the title), as shown in [step 2](#preview-the-search). `POST /business/saved-searches/{savedSearchId}/run` does the same run. Both are for people searches: a company search answers `422 SAVED_SEARCH_TYPE_MISMATCH`, so run it with `POST /business/companies/search` and `savedSearchId` instead.

```bash theme={null}
curl -X POST https://api.tryfuse.ai/api/v1/business/saved-searches/68a1f7409c41d20014b3ed31/run \
  -u "af_YOUR_KEY:"
```

```json theme={null}
{
  "profiles": ["…"],
  "scrollToken": "eyJwYWdlIjoyfQ",
  "total": 4805968,
  "filters": {
    "job_title_levels": [{ "status": "include", "value": "cxo" }],
    "industry": [{ "status": "include", "value": "financial services" }],
    "location_country": [{ "status": "include", "value": "united states" }]
  },
  "ignoredFilterKeys": []
}
```

`ignoredFilterKeys` lists any stored filter keys that could not be translated into search fields. This happens with stale saved searches — ones saved against filter names that have since changed. Ignored filters are dropped, not approximated, so a non-empty array means the replay ran with **fewer constraints than the original search** and the results are broader than what you saved.

<Note>
  Check `ignoredFilterKeys` before acting on replayed results. If it is non-empty, recreate the search with current filter keys (start from [step 1](#look-up-the-accepted-filter-values)) rather than saving a broader-than-intended result set to a list.
</Note>

## The same loop, for companies

Every step above has a company twin under `/business/companies/*` (the `companies` scope): `filter-options` serves the enum vocabularies (industries, tags, NAICS codes, countries, accelerators), `autocomplete` resolves company names (+ `orgType=investor`) and office locations, `POST /companies/search` takes the same one-of-four filter source and echoes resolved `filters` + `total` with cursor paging, and `POST /companies/search/save-to-list` saves into a company list (create-or-reuse by name). There is no `enrich` switch on the company save — Fiber firmographics land on the saved companies automatically. Full request/response shapes are in the [API reference](/api-reference).

The company save takes the same body as the prospect one: the `filters` you searched with, a required `limit`, and `listId` or `listName` for the destination. It answers `202` with `listIds` and fills the list in the background, so follow the list's `completionStatus` as in [long-running work](/concepts/long-running-work).

<Tip>
  To go from a list to a launched campaign in one script, follow the [AI outbound campaign workflow](/workflows/ai-campaign-from-a-prompt). It starts from a plain-English description of your buyer.
</Tip>

## Next steps

<CardGroup cols={2}>
  <Card title="Add custom columns" icon="table-columns" href="/guides/custom-columns">
    Attach your own fields to the list and fill them per contact.
  </Card>

  <Card title="Export the list" icon="file-arrow-down" href="/guides/export-a-list">
    Start an export job, poll it, and download the CSV.
  </Card>

  <Card title="Launch a campaign" icon="paper-plane" href="/guides/campaigns">
    Turn the list into a sequenced outreach campaign.
  </Card>

  <Card title="Upload your own contacts" icon="upload" href="/guides/upload-your-contacts">
    Bring existing contacts into a list instead of searching.
  </Card>
</CardGroup>


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