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

# Turn website visitors into outbound pipeline

> Identify the companies visiting your website, find the decision-makers who work there, add their work emails and start outreach the same day with the Fuse API.

A company that reads your pricing page is further along than one you found in a database. This workflow reads Fuse's website visitor feed, keeps the companies that visited recently with high confidence, finds the decision-makers who work at them, adds their work emails and enrolls them in a campaign. Run it once a day and yesterday's visitors are in outreach the same day.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[Visitor feed] -->|domains| B[People search]
  B -->|listId| C[Visitors list]
  C -->|isDynamic| D[Campaign]
```

## Before you start

| You need | Why |
| - | - |
| Website tracking installed on your site | The visitor feed answers `404 WEBSITE_TRACKING_NOT_SET_UP` until it is set up. |
| An API key with the `intent`, `search`, `lists`, `contacts` and `campaigns` [scopes](/concepts/scopes) | The feed, the people search, the list and the campaign each sit on a different surface. |
| A company list of your customers (optional) | Lets you leave current customers out. |
| Credits | Identifying visitors costs 10 credits per new visitor matched to a company and 5 per identified visit, up to a monthly cap you control. Saving people costs 20 to 50 per work email found. |

## Step by step

<Steps>
  <Step title="Check your tracking setup">
    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/website-tracking -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "tracking": {
        "domains": ["brightpay.com"],
        "trackedPages": ["pricing", "demo"],
        "script": "<script src=\"…\"></script>",
        "credits": { "limit": 5000, "used": 1840 }
      }
    }
    ```

    `script` is the tag to install in your site's `<head>`, and it is `null` when your plan does not include website intent. `credits.used` against `credits.limit` shows how much of this month's identification budget is left. Change the cap with `PATCH /business/website-tracking` and `monthlyCreditLimit`, or send `null` to go back to your plan's monthly allowance.

    To see which page a visitor viewed, set up tracked pages in the app under **Settings → Website**. Each tracked page has its own copy of the script, and the feed then reports that page's name in `page`.
  </Step>

  <Step title="Pull the companies that visited recently">
    Read the company feed for your window, newest first:

    ```bash theme={null}
    curl -G https://api.tryfuse.ai/api/v1/business/website-visitors \
      -u "$FUSE_API_KEY:" \
      --data-urlencode "type=companies" \
      --data-urlencode "since=2026-10-01" \
      --data-urlencode "sortBy=lastVisitDate" \
      --data-urlencode "sortOrder=desc" \
      --data-urlencode "pageSize=100"
    ```

    ```json theme={null}
    {
      "visitors": [
        {
          "companyName": "Brightpay",
          "companyWebsite": "brightpay.com",
          "companyIndustry": "Financial Services",
          "companySize": "201-500",
          "count": 14,
          "lastVisitDate": "2026-10-01",
          "confidenceInterval": "high",
          "page": "pricing"
        }
      ],
      "pagination": { "total": 2875, "page": 1, "pageSize": 100, "totalPages": 29 }
    }
    ```

    `since` (an ISO date, inclusive) reads only visits from that day on: `count` and `lastVisitDate` then cover the window, and companies with no visit in it are left out. Without it the feed covers all time. Keep rows whose `confidenceInterval` is `high` (add `moderate` for more reach) and, if you track pages, whose `page` is one that signals intent. Collect each `companyWebsite`.

    The feed returns the same companies every day, so record the domains you have already processed and skip them next time.
  </Step>

  <Step title="Leave out customers and accounts you already work">
    If you keep your customers in a company list, read its domains and remove them from today's set:

    ```bash theme={null}
    curl "https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e977/filter-values?field=domain" \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    { "field": "domain", "values": ["acme.com", "globex.com"], "total": 2 }
    ```

    `filter-values` reads company lists only, up to 1,000 rows per call; page through a longer list with `start`. To build one from a contact list of your customers, use `POST /business/lists/{listId}/derive-company-list`, as described in [Working with lists](/guides/working-with-lists#turn-contacts-into-companies).
  </Step>

  <Step title="Find the decision-makers at those companies">
    Pass the domains as `job_company_website`, up to 1,000 at a time, alongside the seniority and function you sell to. Preview with a small `size` to check the count:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/prospects/search \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "filters": {
          "job_company_website": ["brightpay.com", "rivian.com"],
          "job_title_levels": [{ "status": "include", "value": "cxo" }, { "status": "include", "value": "vp" }, { "status": "include", "value": "director" }],
          "job_title_role": [{ "status": "include", "value": "finance" }]
        },
        "size": 5
      }'
    ```

    `total` in the response is the number of matching people. Domains can be sent with or without `https://` and `www.`, and seniority and role values come from `GET /business/prospects/filter-options`.
  </Step>

  <Step title="Save them with work emails">
    ```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_company_website": ["brightpay.com", "rivian.com"],
          "job_title_levels": [{ "status": "include", "value": "cxo" }, { "status": "include", "value": "vp" }, { "status": "include", "value": "director" }],
          "job_title_role": [{ "status": "include", "value": "finance" }]
        },
        "listName": "Website visitors - decision makers",
        "limit": 300,
        "enrich": "email"
      }'
    ```

    ```json theme={null}
    { "listIds": ["68b1c0aa9c41d20014b40c01"], "enrich": "email", "totalContacts": 212, "estimatedMinutes": 11, "jobId": "68b1c0ab9c41d20014b40c09", "status": "pending" }
    ```

    `listName` reuses the list when it already exists, so each day's run appends to the same list. Poll `GET /business/jobs/{jobId}` with the `jobId` from the `202` until `status` is `completed`.
  </Step>

  <Step title="Add individually identified visitors">
    The `type=people` feed names individual visitors. For each high-confidence row with a `personLinkedin`:

    1. `POST /business/contacts/resolve` with `{ "linkedinUrl": "…" }`. If it answers with a `contactId`, the person is already in Fuse: add them with `POST /business/lists/{listId}/members`.
    2. If it answers `404 CONTACT_NOT_FOUND`, upload them with `POST /business/lists/{listId}/contacts`, sending the LinkedIn URL and the company you know.
    3. Enrich both groups with `POST /business/lists/{listId}/enrich`, `mode: "email"` and their `contactIds`. Contacts that already have an email are not charged.

    Resolving before uploading keeps repeat runs from creating duplicate contacts. See [Upload your contacts](/guides/upload-your-contacts#resolve-a-contact-id-from-a-linkedin-url).
  </Step>

  <Step title="Start a dynamic campaign on the list">
    Do this once. A dynamic campaign enrolls everyone each daily run adds:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "campaignName": "Website visitors - decision makers",
        "campaignType": "ai-generated",
        "listId": "68b1c0aa9c41d20014b40c01",
        "isDynamic": true,
        "channels": ["email", "email"],
        "ctaLink": "https://example.com/demo",
        "userPrompt": "Lead with the problem we solve for multi-country finance teams and offer a 20-minute walkthrough. Do not mention their website visit.",
        "excludeContacted": "last_3_months",
        "excludeCompanyLists": ["68a1f20b9c41d20014b3e977"]
      }'
    ```

    `excludeCompanyLists` is a safety net that keeps the campaign out of your customers' accounts even if one slips into the list. Wait for initialization, preview and approve as in steps 7 to 9 of [Build an AI outbound campaign from a prompt](/workflows/ai-campaign-from-a-prompt).

    <Note>
      Write about the problem your product solves rather than the visit itself. Relevance earns replies, and a message that opens with "I saw you on our website" rarely does.
    </Note>
  </Step>

  <Step title="Measure the pipeline">
    `GET /business/analytics/website-intent?range=7d` returns visits, unique companies and people, and high-confidence visits, and `GET /business/campaigns/{campaignId}/stats` shows what the outreach produced. See [Analytics](/guides/analytics).
  </Step>
</Steps>

## Run it every day

Schedule steps 2 to 6 once a day. Each run searches only the domains that are new since the last run, and the dynamic campaign enrolls the new contacts within about 30 minutes, up to 200 at a time.

## What it costs

| Step | Credits |
| - | - |
| Identifying visitors | 10 per new visitor matched to a company and 5 per identified visit, up to your monthly `credits.limit` |
| Previewing the people search | 2 per profile returned |
| Saving with `enrich: "email"` | 20 to 50 per work email found, nothing when none is found |
| Uploading identified visitors | Free. Enriching them costs 20 to 50 per email found. |
| Reading the feed, tracking settings and analytics | Free |

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `404 WEBSITE_TRACKING_NOT_SET_UP` | The workspace has no tracking set up. | Set up website tracking in the app under **Settings → Website** and install the script. |
| `script` is `null` | Your plan does not include website intent. | Contact [support](mailto:support@fuseai.com) about your plan. |
| `page` is always `null` | Visits come through the main script, which carries no page name. | Set up tracked pages in the app and install each page's own script. |
| The people search returns 0 | The visiting companies are small or new, or the role and seniority filters are too narrow. | Widen `job_title_levels` and check `total` in the preview before saving. |
| `422 VALIDATION_FAILED` on `PATCH /business/website-tracking` | `trackedPages` was sent without `domains`. | Send both. `domains` replaces the stored list, so include every domain. |
| `422 LIST_INCOMPATIBLE_WITH_CHANNELS` creating the campaign | The list is empty, or its reachability stats have not caught up with enrichment. | Wait for the first save's enrichment job to reach `completed`, then retry after a minute or two. |
| `429 RATE_LIMITED` | Website intent allows 30 requests a minute and people search 20. | Wait for the `Retry-After` seconds. |

## Run the whole workflow

<Accordion title="Python script: daily website visitor sync">
  ```python theme={null}
  """Daily job: turn yesterday's high-confidence website visitors into enriched contacts in a campaign list."""
  import datetime
  import json
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")
  LIST_NAME = "Website visitors - decision makers"
  CUSTOMERS_LIST_ID = os.environ.get("FUSE_CUSTOMERS_LIST_ID")  # optional: a company list of your customers
  STATE_FILE = "processed_domains.json"


  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_for_enrichment(job_id, delay=10, max_delay=60):
      """Poll an enrichment job until it ends. A stopped job waits for a top-up and a resume in the app."""
      while True:
          time.sleep(delay)
          job = call("GET", f"/jobs/{job_id}")["job"]
          if job["status"] in ("completed", "cancelled", "failed", "stopped"):
              return job
          delay = min(delay * 1.5, max_delay)


  def read_domains(list_id):
      """filter-values reads up to 1,000 rows at a time, so page through a longer list with start."""
      domains, start = set(), 1
      while True:
          page = call("GET", f"/lists/{list_id}/filter-values",
                      params={"field": "domain", "start": start, "amount": 1000})
          domains.update(page["values"])
          start += 1000
          if start > page["total"]:
              return domains


  def bare(domain):
      domain = domain.lower().strip()
      for prefix in ("https://", "http://", "www."):
          domain = domain.removeprefix(prefix)
      return domain.rstrip("/")


  def recent_visitors(feed, since):
      """Yield feed rows with a visit on or after `since` (YYYY-MM-DD), newest first."""
      page = 1
      while True:
          body = call("GET", "/website-visitors", params={
              "type": feed, "since": since, "sortBy": "lastVisitDate", "sortOrder": "desc",
              "pageSize": 100, "page": page,
          })
          yield from body["visitors"]
          if page >= body["pagination"]["totalPages"]:
              return
          page += 1


  since = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
  processed = set(json.load(open(STATE_FILE))) if os.path.exists(STATE_FILE) else set()

  # 2. Companies that visited since yesterday, with high confidence
  domains = {
      bare(row["companyWebsite"])
      for row in recent_visitors("companies", since)
      if row["confidenceInterval"] == "high" and row["companyWebsite"]
  } - processed

  # 3. Leave out customers
  if CUSTOMERS_LIST_ID:
      domains -= {bare(domain) for domain in read_domains(CUSTOMERS_LIST_ID)}

  # 4-5. Find and save the decision-makers at those companies
  list_id = None
  if domains:
      filters = {
          "job_company_website": sorted(domains)[:1000],
          "job_title_levels": [{"status": "include", "value": level} for level in ("cxo", "vp", "director")],
          "job_title_role": [{"status": "include", "value": "finance"}],
      }
      total = call("POST", "/prospects/search", json={"filters": filters, "size": 1})["total"]
      print(f"{len(domains)} new companies, {total} matching decision-makers")
      if total:
          saved = call("POST", "/prospects/search/save-to-list", json={
              "filters": filters, "listName": LIST_NAME, "limit": min(total, 500), "enrich": "email",
          })
          list_id = saved["listIds"][0]
          print("Enrichment", wait_for_enrichment(saved["jobId"])["status"])

  # 6. Add identified people, resolving before uploading so repeat runs never duplicate them
  if list_id:
      known, uploaded = [], []
      for row in recent_visitors("people", since):
          if row["confidenceInterval"] != "high" or not row["personLinkedin"]:
              continue
          try:
              known.append(call("POST", "/contacts/resolve", json={"linkedinUrl": row["personLinkedin"]})["contactId"])
          except FuseError as error:
              if error.code != "CONTACT_NOT_FOUND":
                  raise
              contact = {"linkedinUrl": row["personLinkedin"]}
              if row["companyName"]:
                  contact["companyName"] = row["companyName"]
              if row["companyWebsite"]:
                  contact["companyDomain"] = bare(row["companyWebsite"])
              uploaded += call("POST", f"/lists/{list_id}/contacts", json={"contacts": [contact]})["contactIds"]
      if known:
          call("POST", f"/lists/{list_id}/members", json={"contactIds": known[:5000]})
      people = known + uploaded
      for start in range(0, len(people), 500):
          try:
              call("POST", f"/lists/{list_id}/enrich", json={"mode": "email", "contactIds": people[start:start + 500]})
          except FuseError as error:
              if error.code != "ALL_CONTACTS_ALREADY_ENRICHED":
                  raise
      print(f"Added {len(known)} known and {len(uploaded)} new identified visitors")

  json.dump(sorted(processed | domains), open(STATE_FILE, "w"))
  ```
</Accordion>

## Do it from Claude

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

> Which companies visited our site yesterday with high confidence? Find the finance directors, VPs and C-level people at those companies, save them with work emails to "Website visitors - decision makers", and leave out anyone at our existing customers.

The website visitor tools are read-only, so the assistant reads the visitor report, then searches for the people and saves them with the prospect search tools. Ask it to draft a dynamic campaign on the list in a follow-up request.

## Related

<CardGroup cols={2}>
  <Card title="Analytics" icon="chart-line" href="/guides/analytics">
    Website intent, campaign and enrichment roll-ups.
  </Card>

  <Card title="Build a list from search" icon="magnifying-glass" href="/guides/build-a-list-from-search">
    People search filters and the accepted vocabularies.
  </Card>

  <Card title="Upload your contacts" icon="upload" href="/guides/upload-your-contacts">
    Resolve LinkedIn URLs to contacts and manage list membership.
  </Card>

  <Card title="Job change outreach" icon="briefcase" href="/workflows/job-change-outreach">
    Another always-on pipeline, driven by people starting new roles.
  </Card>
</CardGroup>


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