Deduplicating a CRM Export by Company Domain over REST with Python
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Deduplicating a CRM Export by Company Domain over REST with Python
A CRM often holds the same company several times under different spellings, some with a domain and some without. This example fills in missing domains with the Company Domain routine, groups records by normalized domain, keeps one record per group, and writes a clean CSV plus a list of what was merged. Domains are looked up once for each distinct company name, not once for each record.
What you will build
A script that reads crm.csv, resolves domains only for records that lack one, groups on the normalized domain, keeps the most complete and most recently updated record per group, and writes crm_clean.csv.
crm.csv (id, company_name, domain, owner, updated_at)
↓
normalize existing domains (lowercase, strip scheme, www., path)
↓
distinct normalized names of records with no domain
↓ POST /routines/{Company Domain}/run (1 credit per distinct name)
fill domains, group by normalized domain
↓
keep the best record per group -> crm_clean.csv, merged list
AI Prompt
Implement a Python script that deduplicates a CRM company export using the Clay Public API.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Input crm.csv has the columns id, company_name, domain, owner, updated_at. Some rows have no domain.
- Normalize an existing domain by lowercasing it, taking the hostname from a URL when one is given, and removing a leading "www.".
- Normalize a company name by lowercasing it, removing periods and commas, removing a trailing "inc", "llc", "ltd", "corp" or "co", and collapsing whitespace.
- For records with no domain, look up each distinct normalized name once with the "Company Domain" routine, function:t_0tjqm9rUjfGY4MunGDM. Its input is "Company Name" and its result is { "Domain": "<domain>" }. It costs 1 credit per row. Run it with POST /routines/{id}/run, poll GET /routines/run/{routine_run_id}/results until status is not "in_progress", and match rows by id, because results do not come back in input order.
- A record whose domain stays empty after the lookup is kept as its own group.
- Group by normalized domain. In each group keep the record with the most filled fields among company_name, domain and owner, and break ties with the latest updated_at.
- Write crm_clean.csv and print the number of input records, clean records, names looked up, and domains resolved, one line per merged group, and the credits spent.
- A lookup returns the domain Clay has for the company, which can differ from the domain a CRM stores. Do not assume two records for the same company share a domain.
- Run the verification step below before finishing.
Prerequisites
- Python 3.10+ and
pip install requests - A Clay Public API key (
clay_scoped_...) - At least 3 credits for the three distinct names that need a lookup
1. Set up the project
mkdir clay-crm-dedupe && cd clay-crm-dedupe python3 -m venv .venv && source .venv/bin/activate pip install requests
Create the input file. It has three spellings of Notion, two of Figma, two of Stripe, and one Linear record, and only some of the records have a domain:
cat > crm.csv <<'CSV' id,company_name,domain,owner,updated_at 1,Notion,notion.so,dana,2026-06-01 2,"Notion Labs, Inc.",,,2026-02-11 3,NOTION,https://www.notion.so/,sam,2026-09-30 4,Figma,figma.com,,2026-03-04 5,Figma Inc,,jo,2026-08-19 6,Linear,linear.app,dana,2026-07-07 7,Stripe,,sam,2026-05-20 8,"Stripe, Inc.",,,2026-01-09 CSV
2. Configure credentials
export CLAY_API_KEY=clay_scoped_...
3. Implement the script
Save this shared helper as clay.py:
import os
import time
import requests
BASE = "https://api.clay.com/public/v0"
session = requests.Session()
session.headers.update({"clay-api-key": os.environ["CLAY_API_KEY"], "content-type": "application/json"})
def clay(path, method="GET", body=None):
res = session.request(method, f"{BASE}{path}", json=body, timeout=60)
if not res.ok:
raise RuntimeError(f"{res.status_code} {path}: {res.text}")
return res.json()
def balance():
return clay("/credits/balance")["balance"]
def all_results(run_id):
"""Fetch every row. The endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain."""
page = clay(f"/routines/run/{run_id}/results?limit=100")
rows = list(page.get("data", []))
while page.get("cursor"):
page = clay(f"/routines/run/{run_id}/results?limit=100&cursor={page['cursor']}")
rows.extend(page.get("data", []))
page["data"] = rows
return page
def run_routine(routine_id, rows, timeout_s=600):
started = clay(f"/routines/{routine_id}/run", "POST", {"items": rows})
run_id = started["routine_run_id"]
deadline = time.time() + timeout_s
while time.time() < deadline:
time.sleep(5)
results = all_results(run_id)
if results["status"] != "in_progress":
return results
raise RuntimeError(f"run {run_id} still in progress after {timeout_s} seconds")
Save the script as crm_dedupe.py:
import csv
import json
import re
import sys
import time
from collections import defaultdict
from urllib.parse import urlparse
from clay import balance, run_routine
COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM"
def normalize_domain(value):
value = value.strip().lower()
if not value:
return ""
host = urlparse(value if "//" in value else "//" + value).hostname or ""
return host.removeprefix("www.")
def normalize_name(value):
value = re.sub(r"[.,]", "", value.lower())
value = re.sub(r"\s+(inc|llc|ltd|corp|co)$", "", value)
return re.sub(r"\s+", " ", value).strip()
def main():
with open("crm.csv", newline="") as f:
records = list(csv.DictReader(f))
for r in records:
r["domain_norm"] = normalize_domain(r["domain"])
# Resolve a domain only for records that lack one, and only once per distinct normalized name.
missing = sorted({normalize_name(r["company_name"]) for r in records if not r["domain_norm"]})
before = balance()
resolved = {}
if missing:
results = run_routine(COMPANY_DOMAIN, [{"id": str(i), "inputs": {"Company Name": n}} for i, n in enumerate(missing)])
for item in results.get("data", []):
domain = (item.get("result") or {}).get("Domain", "")
resolved[missing[int(item["id"])]] = normalize_domain(domain)
for r in records:
if not r["domain_norm"]:
r["domain_norm"] = resolved.get(normalize_name(r["company_name"]), "")
r["domain_source"] = "clay"
else:
r["domain_source"] = "crm"
groups = defaultdict(list)
for r in records:
groups[r["domain_norm"] or f"unresolved:{r['id']}"].append(r)
# Keep the record with the most filled fields, then the most recently updated.
def completeness(r):
return (sum(1 for k in ("company_name", "domain", "owner") if r[k].strip()), r["updated_at"])
kept, merged = [], []
for domain, group in groups.items():
group.sort(key=completeness, reverse=True)
kept.append(group[0])
if len(group) > 1:
merged.append({"domain": domain, "kept": group[0]["id"], "merged_away": [g["id"] for g in group[1:]]})
with open("crm_clean.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["id", "company_name", "domain", "owner", "updated_at"])
for r in sorted(kept, key=lambda r: r["id"]):
writer.writerow([r["id"], r["company_name"], r["domain_norm"], r["owner"], r["updated_at"]])
time.sleep(5)
print(json.dumps({"input_records": len(records), "clean_records": len(kept), "domains_resolved": sum(1 for v in resolved.values() if v), "names_looked_up": len(missing)}))
for m in merged:
print(json.dumps(m))
print(json.dumps({"credits_spent": before - balance()}))
if __name__ == "__main__":
try:
main()
except RuntimeError as err:
print(err, file=sys.stderr)
sys.exit(1)
4. Run
python3 crm_dedupe.py cat crm_clean.csv
5. Verify the result
Output of the run on 2026-10-12, workspace 1322578:
{"input_records": 8, "clean_records": 5, "domains_resolved": 3, "names_looked_up": 3}
{"domain": "notion.so", "kept": "3", "merged_away": ["1"]}
{"domain": "figma.com", "kept": "5", "merged_away": ["4"]}
{"domain": "stripe.com", "kept": "7", "merged_away": ["8"]}
{"credits_spent": 3.0}
id,company_name,domain,owner,updated_at
2,"Notion Labs, Inc.",firstround.com,,2026-02-11
3,NOTION,notion.so,sam,2026-09-30
5,Figma Inc,figma.com,jo,2026-08-19
6,Linear,linear.app,dana,2026-07-07
7,Stripe,stripe.com,sam,2026-05-20
The check is a set of properties: names_looked_up equals the number of distinct normalized names among records with no domain, credits_spent equals names_looked_up, every merged group lists one kept id and at least one merged id, and clean_records plus the total merged-away ids equals input_records. In this run 3 groups merged and the "Notion Labs, Inc." record stayed separate because of the wrong lookup described below. Treat the resolved domains as that day's answers, not fixed values.
How it works
Existing domains are normalized locally, which costs nothing. Only records with no domain go to the routine, and only once for each distinct normalized name, so "Stripe" and "Stripe, Inc." share a single lookup. The returned domains are normalized the same way and the records are grouped on them.
Within a group the script keeps the record with the most filled fields, then the newest updated_at. The merged ids are printed rather than deleted from the CRM, so the cleaned file can be reviewed before anything is changed at the source.
Common issues
A lookup can return the wrong domain
The lookup for "Notion Labs, Inc." returned firstround.com, a different company, so record 2 stayed in its own group instead of merging with the two Notion records that carry notion.so. The routine returned a domain with status complete, and nothing in the result marks it as a poor match. Review lookups for ambiguous names before trusting the grouping, and prefer records that already have a CRM domain as the anchor for a group.
The same company can have two domains
Records that normalize to different domains are not grouped, even when they describe one company. Enrich Company returned notion.com for the input notion.so on 2026-10-12, so a company can have more than one live domain. If that matters, resolve every record to the canonical domain with Enrich Company, which returns a domain field, before grouping.
Name normalization is deliberately simple
The suffix list covers inc, llc, ltd, corp, and co. Names with other suffixes, translated names, and abbreviations are looked up as written, and each distinct spelling is billed once.
More than 20 distinct names
The lookup run returns 20 rows per page by default. The shared helper requests limit=100 and follows the cursor, which matters for a real export with hundreds of distinct names, and the CLI help for the same inline run states a limit of 100 names per request.
Merging is a report
The script writes a new file and a list of merged ids. It does not change the CRM. Apply the merges with your CRM's own merge feature so that activity history and owners are combined there.
Next steps
- Removing Duplicate Rows Before Enrichment with TypeScript
- Resolving Company Names to Domains with the Clay CLI
verification: status: verified tested_at: "2026-10-12" product_version: "public/v0" command: "python3 crm_dedupe.py" expected_result: "Records without a domain are looked up once per distinct normalized name, records are grouped by normalized domain, merged groups are listed, and credits_spent equals names_looked_up."