clay.com

Command Palette

Search for a command to run...

Ranking Lookalike Companies by Weighted Similarity over REST with Python

Last updated: 10/14/2026

AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.

Ranking Lookalike Companies by Weighted Similarity over REST with Python

A flat list of companies that match a filter gives a sales team nothing to prioritize. This Python example weights each best customer by contract value, builds a profile from the weighted attributes, searches on the fields that should not vary, and ranks the candidates by how close their size and ownership type are to the profile. The ranked file has a score for each company.

What you will build

A script that reads customers.csv (domain and contract value), enriches the customers, computes a value-weighted industry, country, company type and average size, runs one 50-record search, scores every candidate from 30 to 100, and writes the top 10 to lookalikes_ranked.csv.

customers.csv (domain, contract_value)
    ↓ Enrich Company, 1 credit per customer
weighted profile: industry, country, type, mean size position
    ↓ POST /search/filters-mode  {industries, country_names}
    ↓ POST /search/filters-mode/{id}/run  {limit: 50}
score = 30 (industry and country matched by the filter)
      + up to 50 for size closeness + 20 for the same company type
    ↓
lookalikes_ranked.csv

AI Prompt

Write a Python script that ranks lookalike companies for a set of customers using the Clay Public API.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Input customers.csv has the columns domain and contract_value. The API has no lookalike search, so build the profile from enrichment and rank the search results locally.
- Enrich each customer with the "Enrich Company" routine, function:t_0tjqm9uSZcH4rf4MDrK. Input: "Company Identifier". The result is nested under the key "Enrich Company" and includes domain, industry, size (a label such as "501-1,000 employees"), country (an ISO code such as "US") and type (such as "Privately Held"). Cost: 1 credit per row. Run it with POST /routines/{id}/run, poll GET /routines/run/{routine_run_id}/results, and match rows by id.
- Weight every attribute by contract_value. The profile industry, country and type are the values with the largest total weight. The profile size is the weighted mean of each customer's position in this ordered list: Self-employed, 2-10, 11-50, 51-200, 201-500, 501-1,000, 1,001-5,000, 5,001-10,000, 10,001+.
- Search with POST /search/filters-mode, body { source_type: "companies", filters: { industries: [<industry>], country_names: [<country name>] } }, then POST /search/filters-mode/{search_id}/run with { limit: 50 }. The response has data, has_more and period_quota. Searching spends no credits and uses the search quota.
- A search row has name, domain, size, type, industry and country. Some rows have no domain. Skip them and count them.
- Drop the customers themselves, by typed and returned domain.
- Score each candidate: 30 for matching the search filters, plus 50 minus 12.5 for each size step away from the profile size (never below 0), plus 20 when the type equals the profile type. Write the top 10 to lookalikes_ranked.csv.
- Report the credits spent from GET /credits/balance before and after.
- Run the verification step below before finishing.

Prerequisites

  • Python 3.10+ with requests
  • A Clay Public API key
  • At least 4 credits for the sample customers

1. Set up the project

mkdir clay-rank-lookalikes && cd clay-rank-lookalikes
python3 -m venv .venv && source .venv/bin/activate
pip install requests
export CLAY_API_KEY=clay_scoped_...

Create the customer file. The contract values are made up for the example, and they are what make the profile lean toward the larger customer:

domain,contract_value
stripe.com,120000
notion.so,60000
figma.com,90000
linear.app,18000

2. Implement the script

The shared helper, clay.py, keeps the key header on one requests.Session:

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")

The ranking script, rank_lookalikes.py:

import csv
import json
import sys
import time
from collections import defaultdict

from clay import balance, clay, run_routine

ENRICH_COMPANY = "function:t_0tjqm9uSZcH4rf4MDrK"
CANDIDATES = 50  # one search request, the free-plan per-request maximum
TOP = 10

SIZE_ORDER = [
    "Self-employed", "2-10 employees", "11-50 employees", "51-200 employees", "201-500 employees",
    "501-1,000 employees", "1,001-5,000 employees", "5,001-10,000 employees", "10,001+ employees",
]
COUNTRY_NAME = {"US": "United States"}


def weighted_top(pairs):
    totals = defaultdict(float)
    for value, weight in pairs:
        totals[value] += weight
    return max(totals, key=totals.get)


def main():
    with open("customers.csv", newline="") as f:
        customers = list(csv.DictReader(f))
    weight = {c["domain"]: float(c["contract_value"]) for c in customers}

    before = balance()
    results = run_routine(
        ENRICH_COMPANY,
        [{"id": c["domain"], "inputs": {"Company Identifier": c["domain"]}} for c in customers],
    )
    seeds = [
        (weight[r["id"]], r["result"]["Enrich Company"])
        for r in results.get("data", [])
        if r["status"] == "complete" and r.get("result", {}).get("Enrich Company")
    ]
    if not seeds:
        raise RuntimeError("No customer could be enriched")

    # The profile is weighted by contract value, so one large customer counts for more than a small one.
    industry = weighted_top((e["industry"], w) for w, e in seeds)
    country = weighted_top((e["country"], w) for w, e in seeds)
    kind = weighted_top((e["type"], w) for w, e in seeds)
    size_pos = sum(w * SIZE_ORDER.index(e["size"]) for w, e in seeds if e["size"] in SIZE_ORDER) / sum(w for w, _ in seeds)
    print(json.dumps({"industry": industry, "country": country, "type": kind, "mean_size_position": round(size_pos, 2)}))

    # Search on the two fields that should not vary, then rank on size and type locally.
    filters = {"industries": [industry], "country_names": [COUNTRY_NAME.get(country, country)]}
    search_id = clay("/search/filters-mode", "POST", {"source_type": "companies", "filters": filters})["search_id"]
    page = clay(f"/search/filters-mode/{search_id}/run", "POST", {"limit": CANDIDATES})

    exclude = {c["domain"] for c in customers} | {e["domain"] for _, e in seeds}
    scored = []
    no_domain = 0
    for c in page["data"]:
        if not c.get("domain"):
            no_domain += 1  # some search rows carry no domain, and a domainless row cannot be enriched later
            continue
        if c["domain"] in exclude:
            continue
        pos = SIZE_ORDER.index(c["size"]) if c["size"] in SIZE_ORDER else None
        size_score = 0 if pos is None else max(0, 50 - abs(pos - size_pos) * 12.5)
        type_score = 20 if c["type"] == kind else 0
        scored.append({"domain": c["domain"], "name": c["name"], "size": c["size"], "type": c["type"], "score": round(30 + size_score + type_score)})
    scored.sort(key=lambda r: -r["score"])

    with open("lookalikes_ranked.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["domain", "name", "size", "type", "score"])
        writer.writeheader()
        writer.writerows(scored[:TOP])
    for r in scored[:TOP]:
        print(json.dumps(r))

    time.sleep(5)
    print(json.dumps({"searched": len(page["data"]), "ranked": len(scored), "no_domain": no_domain, "written": min(TOP, len(scored)), "credits_spent": before - balance()}))


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as err:
        print(err, file=sys.stderr)
        sys.exit(1)

3. Run

python3 rank_lookalikes.py
head -4 lookalikes_ranked.csv

4. Check the output

The run on 2026-10-12:

{"industry": "Technology, Information and Internet", "country": "US", "type": "Privately Held", "mean_size_position": 6.02}
{"domain": "turing.com", "name": "Turing", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "uplers.com", "name": "Uplers", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "ripple.com", "name": "Ripple", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "waymo.com", "name": "Waymo", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "idc.com", "name": "IDC", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "bcg.com", "name": "BCG X", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "mcafee.com", "name": "McAfee", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "x.ai", "name": "SpaceXAI", "size": "1,001-5,000 employees", "type": "Privately Held", "score": 100}
{"domain": "yahoo.com", "name": "Yahoo", "size": "5,001-10,000 employees", "type": "Privately Held", "score": 88}
{"domain": "hopper.com", "name": "HTS (Hopper Technology Solutions)", "size": "501-1,000 employees", "type": "Privately Held", "score": 87}
{"searched": 50, "ranked": 48, "no_domain": 1, "written": 10, "credits_spent": 4}

What to check is a set of properties. The profile industry is the one with the largest contract-value total. searched is 50. ranked is searched minus the skipped rows and the customers. Scores fall between 30 and 100, and the file is sorted from highest to lowest. credits_spent equals the number of customers. The company names depend on the day's search results.

How it works

The profile is a value-weighted vote. In this run the industry with the largest total contract value was Technology, Information and Internet, and the weighted size position was 6.02, which sits in the 1,001-5,000 bucket. A count-based vote over a different seed list in the TypeScript example picked Software Development, so the weights, and the seeds themselves, decide which segment you search.

The search filters carry only industry and country, because a hard size filter would hide near misses. Size and type are scored locally instead, which turns a pass-or-fail filter into a ranking.

The score is a heuristic and not a model output. The 30 points are given to every candidate because the search already required the industry and country match. The weights of 50 and 20 are choices you can tune.

Common issues

A different weighting gives a different segment

Changing the contract values changes the industry label the search uses. If the weighted label is too general for your market, narrow the search with the description_keywords field, which this example does not use.

Search rows without a domain

One of the 50 rows had no domain and was skipped. A candidate without a domain cannot be passed to a routine that takes a domain, so the script counts those rows and does not rank them.

Ties at the top

Several candidates scored 100 because they share the profile's industry, country, type and size bucket. The sort is stable, so ties keep the search's own order. Add a tie-breaker, such as headcount, when the top of the list needs a strict order.

The search quota is separate from credits

All 50 returned records count against period_quota, while the credit balance only moved for the 4 enrichments.

Next steps


verification:
  status: verified
  tested_at: "2026-10-12"
  product_version: "public/v0"
  command: "python3 rank_lookalikes.py"
  expected_result: "Customers are enriched, a value-weighted profile is printed, 50 candidates are searched, the top 10 are written sorted by score between 30 and 100, and credits_spent equals the number of customers."