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

# Clean and enrich your CRM data, then sync it back

> Import a CRM export, merge duplicate contacts, verify and enrich emails and phones, then push the cleaned records back to HubSpot, Salesforce or Attio.

CRM data decays. People change jobs, emails start to bounce, and the same person ends up in the database twice. This workflow imports a CRM export into Fuse, merges duplicate contacts, re-verifies the emails you already have, finds missing emails and phones, keeps your CRM-only fields alongside, and pushes the result back to HubSpot, Salesforce, Zoho, Attio or Pipedrive without overwriting fields your team has already filled in.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[CRM export] -->|importId| B[Import]
  B -->|listId| C[Merge duplicates]
  C --> D[Verify and enrich]
  D --> E[Push to CRM]
```

## Before you start

| You need | Why |
| - | - |
| An API key with the `lists` and `contacts` [scopes](/concepts/scopes) | Imports, enrichment and CRM pushes sit on `lists`, and deduplication on `contacts`. |
| A CRM connected in the app under **Settings → Integrations** | The push uses that connection and the field mapping saved there. See [CRM sync](/crm/overview). |
| An export with an email, LinkedIn URL, or name plus company domain column | An import with no identity column is refused, because its rows could not be matched. |
| Credits | Importing, scanning for duplicates and pushing are free. Re-verifying costs 10 credits per email, and finding data 20 to 50 per email found and 200 per phone found. |

## Step by step

<Steps>
  <Step title="Upload the export">
    Send files up to 10 MB as multipart. Parsing is free and writes nothing to a list yet:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/imports \
      -u "$FUSE_API_KEY:" \
      -F "file=@hubspot-contacts.csv;type=text/csv"
    ```

    ```json theme={null}
    {
      "import": {
        "importId": "dXBsb2Fkcy82OGExZjIwYjljNDFkMjAwMTRiM2U5MDEvM2Y2ZjBlMmEtOGQ0Yi00ZTFhLTk3YzItNWQ4YjFmMGUyYTdjLmNzdg",
        "filename": "hubspot-contacts.csv",
        "rowCount": 4820,
        "headers": ["First Name", "Last Name", "Email", "Company", "Website", "Contact owner", "Record ID"],
        "sampleRows": ["…"],
        "suggestedMapping": {
          "First Name": "First Name",
          "Last Name": "Last Name",
          "Email": "Email",
          "Company": "Company",
          "Website": null,
          "Contact owner": null,
          "Record ID": null
        }
      }
    }
    ```

    For files up to 50 MB, call `POST /business/imports/presign` with the `filename` and `contentType`, `PUT` the raw bytes to the returned `uploadUrl` with the same `Content-Type` within 10 minutes, then call `POST /business/imports` with `{ "importId": "…" }`.
  </Step>

  <Step title="Commit it into a list">
    Confirm the mapping and choose the destination. Leave enrichment off for now, so you never pay to enrich the same person twice before duplicates are merged:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/imports/$IMPORT_ID/commit \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "listName": "HubSpot contacts - Oct 2026",
        "mapping": { "Website": "Company Domain" },
        "onDuplicate": "merge",
        "enrich": "none"
      }'
    ```

    ```json theme={null}
    {
      "import": {
        "importId": "dXBsb2Fkcy82OGExZjIwYjljNDFkMjAwMTRiM2U5MDEvM2Y2ZjBlMmEtOGQ0Yi00ZTFhLTk3YzItNWQ4YjFmMGUyYTdjLmNzdg",
        "listId": "68b2d0119c41d20014b41001",
        "jobId": "3f6f0e2a-8d4b-4e1a-97c2-5d8b1f0e2a7c",
        "mapping": { "First Name": "First Name", "Last Name": "Last Name", "Email": "Email", "Company": "Company", "Website": "Company Domain" },
        "ignoredHeaders": ["Contact owner", "Record ID"]
      }
    }
    ```

    Mapping values are fixed labels: `Full Name`, `First Name`, `Last Name`, `LinkedIn URL`, `Company`, `Company Domain`, `Job Title`, `Job Level`, `Department`, `Industry`, `Country`, `State`, `City`, `Email` and `Phone`. Headers you leave out fall back to the suggestion. Anything that maps to none of them comes back in `ignoredHeaders`; step 5 brings those values back.

    `onDuplicate: "merge"` fills the empty fields of a contact already on the list instead of adding the row twice. Poll `GET /business/lists/{listId}` until `completionStatus` is `complete`. The `jobId` is a reference only and cannot be polled.
  </Step>

  <Step title="Merge duplicate contacts">
    The import only checks the destination list, so the same person can still exist elsewhere in your workspace. Scan the whole workspace for contacts that share an email:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/contacts/dedupe-scan \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "matchFields": ["email"] }'
    ```

    Poll `GET /business/contacts/dedupe-jobs/{jobId}` until `status` is `completed`, then page through `scanResult.groups` with `pageNum` and `limit`. Each group shows a `mergePreview` of the surviving contact and the `lists` its members belong to. Merge the groups you approve:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/contacts/dedupe-merge \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "scanJobId": "68b2d1209c41d20014b41055", "groupIds": ["3f6c2a9d1b8e4c7f"] }'
    ```

    Losing contacts are archived and their list memberships move to the survivor, so your import list still holds each person once. A second scan on `["linkedinUrl"]` catches people stored under two different emails.

    <Warning>
      Merges cannot be undone. Review `mergePreview` for every group before you submit it. The full flow is in [Deduplicate contacts](/guides/deduplicate-contacts).
    </Warning>
  </Step>

  <Step title="Re-verify emails, then fill the gaps">
    First re-verify the emails your CRM already holds, at 10 credits per address:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b2d0119c41d20014b41001/enrich \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "mode": "validate_emails", "limit": 5000 }'
    ```

    `validate_emails` covers at most 5,000 contacts per call, so import larger exports as several lists. When the list is `complete` again, find what is missing:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b2d0119c41d20014b41001/enrich \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "mode": "phone_and_email", "limit": 4820 }'
    ```

    Contacts that already carry an email or phone are not charged for it again. To spend only where it matters, add `filters`, for example `{ "country": ["united states"] }` for phone numbers you will dial. This call answers with a `jobId`: poll `GET /business/jobs/{jobId}` until `status` is `completed`, then read `metrics` on `GET /business/lists/{listId}`. Before it starts, the balance must cover 250 credits for every contact it takes, the most an email and a phone can cost together.
  </Step>

  <Step title="Keep your CRM fields on the rows">
    Imports keep the standard fields only, so CRM fields such as the contact owner arrive in `ignoredHeaders`. Bring them back as [custom columns](/guides/custom-columns):

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68b2d0119c41d20014b41001/columns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "name": "CRM owner", "type": "string" }'
    ```

    Page through `GET /business/lists/{listId}/rows`, match each row to your file by `primaryEmail`, and write the values in batches of up to 500:

    ```bash theme={null}
    curl -X PATCH https://api.tryfuse.ai/api/v1/business/lists/68b2d0119c41d20014b41001/columns/68b2d2009c41d20014b410a1/values \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "values": [{ "contactId": "68b2d0559c41d20014b41011", "value": "Dana Whitfield" }] }'
    ```

    These columns appear in exports, and campaigns can use them as tokens such as `{Column: CRM owner}`. A `url` column with a `label` and `value` makes a clickable link back to the CRM record.
  </Step>

  <Step title="Push the clean list back to your CRM">
    Check which CRMs are connected, then push the list:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/integrations -u "$FUSE_API_KEY:"
    # { "integrations": { "crm": "hubspot", "connected": ["hubspot"] } }

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

    ```json theme={null}
    { "crm": "hubspot", "listIds": ["68b2d0119c41d20014b41001"] }
    ```

    Pushing the whole list creates the people your CRM is missing and, on records it already has, fills only fields that are empty, so your team's edits stand. The push runs in the background with no job to poll, using the field mapping saved in the app. People with no email or phone are not pushed; see [Contacts and companies](/crm/contacts-and-companies#which-people-are-added).

    <Warning>
      Sending `contactIds` pushes only those contacts and overwrites fields on records that already exist in the CRM. Use it when you mean to refresh specific records, not for a routine sync.
    </Warning>
  </Step>
</Steps>

## Run it on a schedule

Repeat the workflow monthly on a fresh export of recently changed records. Commit it into the same list with `listId` and `onDuplicate: "merge"`, scan for duplicates again, then re-run enrichment and the push. Enrichment never charges twice for data a contact already has, so each run pays only for what is new.

## What it costs

| Step | Credits |
| - | - |
| Uploading, parsing and committing with `enrich: "none"` | Free |
| Scanning for duplicates | Free |
| Re-verifying emails | 10 per address |
| Finding emails and phones | 20 to 50 per email found and 200 per phone found |
| Custom columns, the CRM push and exports | Free |

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `422 IMPORT_IDENTITY_MISSING` | No column maps to `LinkedIn URL`, `Email`, or a name plus `Company Domain`. | Map one of them explicitly in `mapping`. |
| `422 IMPORT_FILE_TOO_LARGE` | A multipart file over 10 MB, or a presigned file over 50 MB. | Use the presigned upload, or split the file. |
| `409 IMPORT_ALREADY_COMMITTED` | A commit runs exactly once. | Upload the file again for a new `importId`. |
| `409 SCAN_NOT_COMPLETED` | The merge started before the scan finished. | Poll the scan until it is `completed`. |
| `422 SCAN_HAS_NO_GROUPS` | The scan found no duplicates. | Nothing to merge. |
| `409 ALL_CONTACTS_ALREADY_ENRICHED` | Every contact already has the data you asked for. | Nothing to do, and nothing was billed. |
| `422 CRM_NOT_CONNECTED` | The team's CRM sync user has no live connection to that CRM. | Reconnect it 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. |
| Some people never appear in the CRM | They have no email or phone, or the sync has not run yet. | Enrich them, then check again later. See [CRM troubleshooting](/crm/troubleshooting). |

## Run the whole workflow

<Accordion title="Python script: import, merge, enrich and push">
  ```python theme={null}
  """Import a CRM export, merge duplicates, verify and enrich contacts, then push the list back."""
  import csv
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")
  EXPORT_FILE = "hubspot-contacts.csv"
  LIST_NAME = "HubSpot contacts - Oct 2026"
  CRM = "hubspot"
  EMAIL_HEADER, OWNER_HEADER = "Email", "Contact owner"


  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=120, **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_dedupe(job_id):
      return wait_until(lambda: call("GET", f"/contacts/dedupe-jobs/{job_id}")["dedupeJob"],
                        lambda current: current["status"] in ("completed", "failed"))


  # 1. Upload the export (files over 10 MB go through POST /imports/presign first)
  with open(EXPORT_FILE, "rb") as handle:
      parsed = call("POST", "/imports", files={"file": (EXPORT_FILE, handle, "text/csv")})["import"]
  print(f"{parsed['rowCount']} rows. Suggested mapping: {parsed['suggestedMapping']}")

  # 2. Commit into a list without enrichment
  committed = call("POST", f"/imports/{parsed['importId']}/commit", json={
      "listName": LIST_NAME,
      "mapping": {"Website": "Company Domain"},
      "onDuplicate": "merge",
      "enrich": "none",
  })["import"]
  list_id = committed["listId"]
  print("Ignored headers:", committed["ignoredHeaders"])
  fuse_list = wait_for_list(list_id)

  # 3. Merge duplicates that involve this list
  scan_id = call("POST", "/contacts/dedupe-scan", json={"matchFields": ["email"]})["jobId"]
  if wait_for_dedupe(scan_id)["status"] == "completed":
      group_ids, page = [], 1
      while True:
          result = call("GET", f"/contacts/dedupe-jobs/{scan_id}",
                        params={"pageNum": page, "limit": 200})["dedupeJob"]["scanResult"]
          group_ids += [group["groupId"] for group in result["groups"]
                        if any(member_list["id"] == list_id for member_list in group["lists"])]
          if page >= result["pagination"]["totalPages"]:
              break
          page += 1
      if group_ids and input(f"Merge {len(group_ids)} duplicate groups? This cannot be undone. [y/N] ").lower() == "y":
          merge_id = call("POST", "/contacts/dedupe-merge",
                          json={"scanJobId": scan_id, "groupIds": group_ids})["jobId"]
          print(wait_for_dedupe(merge_id)["mergeResult"])

  # 4. Re-verify existing emails, then find missing emails and phones
  total = call("GET", f"/lists/{list_id}")["list"]["totalRecords"]
  call("POST", f"/lists/{list_id}/enrich", json={"mode": "validate_emails", "limit": min(total, 5000)})
  wait_for_list(list_id)
  try:
      started = call("POST", f"/lists/{list_id}/enrich", json={"mode": "phone_and_email", "limit": min(total, 10000)})
      if started.get("jobId"):
          print("Enrichment", wait_for_enrichment(started["jobId"])["status"])
  except FuseError as error:
      if error.code != "ALL_CONTACTS_ALREADY_ENRICHED":
          raise
  print("Metrics:", call("GET", f"/lists/{list_id}")["list"]["metrics"])

  # 5. Bring the CRM owner back as a custom column
  with open(EXPORT_FILE, newline="") as handle:
      owners = {row[EMAIL_HEADER].strip().lower(): row[OWNER_HEADER]
                for row in csv.DictReader(handle) if row.get(EMAIL_HEADER) and row.get(OWNER_HEADER)}
  try:
      column_id = call("POST", f"/lists/{list_id}/columns", json={"name": "CRM owner", "type": "string"})["column"]["id"]
  except FuseError as error:
      if error.code != "COLUMN_NAME_TAKEN":
          raise
      column_id = next(column["id"] for column in call("GET", f"/lists/{list_id}/columns")["columns"]
                       if column["name"].lower() == "crm owner")
  values, page = [], 1
  while True:
      rows = call("GET", f"/lists/{list_id}/rows", params={"limit": 1000, "pageNum": page})
      for row in rows["data"]:
          owner = owners.get((row.get("primaryEmail") or "").lower())
          if owner:
              values.append({"contactId": row["id"], "value": owner})
      if page >= rows["pagination"]["totalPages"]:
          break
      page += 1
  for start in range(0, len(values), 500):
      call("PATCH", f"/lists/{list_id}/columns/{column_id}/values", json={"values": values[start:start + 500]})
  print(f"Wrote the CRM owner on {len(values)} rows")

  # 6. Push the whole list, which fills empty fields and never overwrites your team's edits
  if CRM not in call("GET", "/integrations")["integrations"]["connected"]:
      raise SystemExit(f"Connect {CRM} in the Fuse app first.")
  print(call("POST", f"/lists/{list_id}/crm-push", json={"crm": CRM}))
  ```
</Accordion>

## Do it from Claude

Deduplication and file imports are not available over the [Fuse MCP server](/mcp/connect) yet, so run steps 1 to 3 with the API. After that, one request covers the rest:

> Find missing work emails and phone numbers for the contacts on my list "HubSpot contacts - Oct 2026", then push the list to HubSpot.

The assistant uses its list and enrichment tools, then pushes the list to HubSpot. 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="Deduplicate contacts" icon="clone" href="/guides/deduplicate-contacts">
    Match fields, merge rules and how scans page their groups.
  </Card>

  <Card title="Working with lists" icon="list" href="/guides/working-with-lists">
    Imports, enrichment modes and pushing to a CRM.
  </Card>

  <Card title="CRM sync" icon="arrows-rotate" href="/crm/overview">
    What Fuse writes to each CRM, and how matching works.
  </Card>

  <Card title="Custom columns" icon="table-columns" href="/guides/custom-columns">
    Column types, writing values and using them in campaigns.
  </Card>
</CardGroup>


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