Skip to content

Workflows · Beginner · 5 min read

Clean a CRM export with the bulk API and re-import the results

Export contacts from HubSpot or any CRM, verify them with the SimpleCold bulk endpoint, and re-import category and reason fields by record ID.

Sep 24, 2026 · SimpleCold team

A CRM export with record IDs and the Result, Reason code and Suggestion columns added by the bulk job, ready to import by record ID

CRMs collect bad addresses quietly. People change jobs, companies rebrand domains, someone imports a trade-show list, and a year later a sizeable slice of your contacts would bounce if you emailed them. The fix is mechanical: export, verify, write the results back as fields, and build your sends around those fields.

This guide does that with the bulk API and a short Python script. It works with any CRM that exports and imports CSV, which is nearly all of them. The examples use HubSpot column names because they are common, but nothing depends on HubSpot. SimpleCold can import a HubSpot list for verification, but it doesn't write anything back to a CRM, so a CSV round trip is how results get into your records.

Step 1: export with a record ID

The single most important column in your export is the CRM's own record ID. Re-importing by email address works, but if an address is wrong, matching on it is fragile. Matching on ID is exact.

In HubSpot, export the contacts view you want with at least these columns:

  • Record ID
  • Email
  • Anything you want to keep for context, such as First Name, Company Name, Contact owner

Other CRMs have the same idea under different names: Id in Salesforce, id in most API exports.

Save as CSV. If your CRM exports .xlsx only, open it and save as CSV with UTF-8 encoding. Accented names survive the trip that way.

Step 2: make sure SimpleCold can find the email column

The bulk endpoint looks for a header named email, e-mail, email address, emailaddress, emails or mail, ignoring case. HubSpot's Email matches. If your CRM uses something like Primary Email or Work Email, rename that header before uploading, or the upload fails with Email column not found in the file.

If the file has two email-like columns, rename the one you don't want checked, so there's no doubt which one gets verified.

All other columns, including Record ID, come back in the result file with their values and headers unchanged, so Record ID is still Record ID. If the same address appears on two rows, it is checked and charged once, and only the first row is kept in the result file. Two CRM records that share an address will therefore come back as one row.

Step 3: upload, wait, download

You can do this part by hand in the dashboard with the bulk email verifier. To automate it, here is the whole round trip in Python:

# clean_crm_export.py
import csv
import io
import os
import sys
import time

import requests

API = "https://simplecold.com/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['SIMPLECOLD_API_KEY']}"


def upload(path):
    with open(path, "rb") as f:
        r = s.post(f"{API}/api-validation/bulk",
                   files={"file": (os.path.basename(path), f, "text/csv")}, timeout=300)
    body = r.json()
    if not r.ok:
        sys.exit(f"upload failed: {body.get('message') or body.get('error')}")
    return body["payload"]["id"]


def wait(job_id):
    while True:
        r = s.get(f"{API}/api-validation/bulk/{job_id}/status", timeout=30)
        if r.status_code == 429:
            # Status polling has its own budget (120 a minute per key); Retry-After says how long.
            time.sleep(int(r.headers.get("Retry-After", 60)))
            continue
        r.raise_for_status()
        p = r.json()["payload"]
        print(f"{p['status']}: {p['progressPercent']}%", file=sys.stderr)
        if p["status"] in ("COMPLETED", "CANCELED"):
            return
        if p["status"] == "FAILED":
            sys.exit("job failed; retry it from the dashboard")
        time.sleep(max(10, min(p.get("etaSeconds") or 30, 120)))


def download(job_id):
    r = s.get(f"{API}/api-validation/bulk/{job_id}/download-result",
              params={"output": "csv", "verifiedOnly": "false"}, timeout=300)
    r.raise_for_status()
    return r.content.decode("utf-8-sig")


if __name__ == "__main__":
    src = sys.argv[1]
    job = upload(src)
    print("job", job, file=sys.stderr)
    wait(job)
    rows = list(csv.DictReader(io.StringIO(download(job))))

    out = src.rsplit(".", 1)[0] + "-import.csv"
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["Record ID", "Email", "Email verification category",
                    "Email verification reason", "Email verification status",
                    "Email verification suggestion"])
        for row in rows:
            w.writerow([row.get("Record ID", ""), row.get("Email", ""),
                        row["Result"], row["Reason"], row["Reason code"],
                        row.get("Suggestion") or ""])
    print("wrote", out, file=sys.stderr)

Run it with python clean_crm_export.py hubspot-contacts.csv.

Two details in that script:

Headers come back as you sent them. The result file keeps Record ID and Email exactly as HubSpot wrote them, and adds readable columns such as Result, Reason, Reason code and Suggestion after them. That's why the script reads row.get("Record ID") and row["Result"] directly.

The import file is narrow on purpose. It carries only the ID, the address and four result fields. Re-importing every column risks overwriting edits someone made in the CRM while the job ran.

The bulk job guide explains each endpoint in more depth, including smarter polling with nextUpdateAt. If the script runs on a server that can receive requests, a bulk.finished webhook can replace the polling loop.

Step 4: create the fields in your CRM

Before importing, create custom contact properties to hold the results. In HubSpot that is Settings, Properties, Create property. A sensible set:

PropertyTypeValues
Email verification categoryDropdownDeliverable, Risky, Undeliverable
Email verification reasonSingle-line textThe Reason column, e.g. "The domain accepts mail for any address, so this mailbox can't be confirmed."
Email verification statusSingle-line textThe Reason code column, e.g. accept_all
Email verification suggestionSingle-line textA corrected address when the domain looked misspelled
Email verified onDateSet during import, or by a workflow

A dropdown for category makes list filters and workflow branches much easier than free text.

Step 5: import by record ID

In HubSpot's importer, choose to update existing contacts and map Record ID to the record ID so rows match exactly. Map the four result columns to the properties you just created. Don't map Email back onto the email property. You exported it, it hasn't changed, and writing it back gains nothing.

Run a small import first, ten rows or so, and spot-check a few contacts before importing the rest.

Step 6: act on the results

Now build segments on the category property:

  • Undeliverable: suppress from all marketing and sales sends. Keep the record for history if you need it, but don't email it. In HubSpot you can set these contacts as non-marketing, which also affects your contact billing tier.
  • Risky: look at the reason code. accept_all (catch-all domain) and role_address addresses are often fine for small, careful sends. disposable addresses rarely belong in a B2B CRM.
  • Risky and no_response: the mail server wouldn't confirm the address. Re-check in a few weeks rather than deleting.
  • Has a suggestion: a person should review these. A suggestion like [email protected] for [email protected] is usually right, but confirm before changing a customer's address.

The category mapping guide has a full table of statuses and recommended actions.

How often to repeat this

Contacts go stale gradually. A quarterly run on the whole database, plus verification of new contacts as they're created, keeps things under control. For new contacts, run the Python batch script nightly against yesterday's additions, or verify at capture with the single endpoint.

Before a big campaign to an old segment, run that segment through again even if it was verified a few months ago. That's the send where a bounce spike would hurt.

For a no-code take on the HubSpot side, see the HubSpot integration page. The API reference is in the API docs.

Check your list before you send it

Start with 200 free credits. Pay per credit after that.