clay.com

Command Palette

Search for a command to run...

Finding Lookalike Companies from Your Best Customers over REST with TypeScript

Last updated: 10/14/2026

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

Finding Lookalike Companies from Your Best Customers over REST with TypeScript

The Clay Public API has no lookalike filter, so this example builds an approximation: it enriches a few best customers, reads their industry, size, and country, and searches for other companies with the same profile. The enrichment cost 1 credit per customer and the search cost no credits.

What you will build

A script that takes a list of customer domains, enriches each one, derives search filters from what they have in common, runs a company search with those filters, and prints companies that are not already in the seed list.

POST /routines/{Enrich Company}/run   (one row per best customer)
    ↓
GET  /routines/run/{id}/results       -> industry, size, country per customer
    ↓
derive filters: most common industry, size buckets, country
    ↓
POST /search/filters-mode             -> { search_id }
POST /search/filters-mode/{id}/run    -> { data[], has_more, period_quota }
    ↓
drop the seeds, print the rest

AI Prompt

Implement a TypeScript script that finds companies similar to a list of best customers using the Clay Public API.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- The API has no lookalike search. GET /search/filters-mode/fields?source_type=companies returns a guidance object that says lookalike company search is not a native public API filter, and that include_company_identifiers is for exact domains only. Approximate it by searching on the seeds' shared attributes.
- Enrich the seeds with the "Enrich Company" routine, function:t_0tjqm9uSZcH4rf4MDrK. Its input is "Company Identifier" (a domain or LinkedIn URL). The result is nested under the key "Enrich Company" and includes name, domain, industry, size, and country. The cost is 1 credit per row.
- Run the routine with POST /routines/{id}/run and body { items: [{ id, inputs }] }, then poll GET /routines/run/{routine_run_id}/results until status is not "in_progress". Results do not come back in input order, so match rows by id.
- Enrich returns the canonical domain, which can differ from the input. notion.so came back as notion.com.
- Enrich returns size as a label such as "501-1,000 employees" and country as an ISO code such as "US". The search filters use different values: sizes takes the upper bound of the bucket below the one you want ("500" returns 501-1,000 employees, "1000" returns 1,001-5,000), and country_names takes names such as "United States".
- Create a search with POST /search/filters-mode and body { source_type: "companies", filters }. It returns { search_id }. Run it with POST /search/filters-mode/{search_id}/run and body { limit }. The response has data, has_more, and period_quota. Searching spends no credits.
- Remove the seed domains, both as typed and as returned by Enrich, from the search results.
- Read GET /credits/balance before and after, and report the credits spent.
- Run the verification step below before finishing.

Prerequisites

  • Node.js 18+, with TypeScript installed as a dev dependency
  • A Clay Public API key (clay_scoped_...)
  • A credit balance of 3 or more, which is what the three seed enrichments spend

1. Create the project

mkdir clay-lookalike && cd clay-lookalike
npm init -y
npm install -D typescript @types/node
mkdir src

Add this tsconfig.json. It lists types explicitly because TypeScript 6 and later stops loading @types packages on its own:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"],
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

No HTTP library is needed because Node.js 18 and later ship fetch. These runs used Node.js 24 and TypeScript 7.

2. Discover the real schema

Check the search guidance and the size buckets before writing the filters:

curl -s -H "clay-api-key: $CLAY_API_KEY" "https://api.clay.com/public/v0/search/filters-mode/fields?source_type=companies" \
  | jq -r '.guidance.behavior[3]'
Lookalike company search, funding stage, Fortune 500, unicorn status, and technographics are not native public API filters.

The sizes filter accepts nine values. A one-row search for each value returned these buckets:

    1 -> Self-employed
    2 -> 2-10 employees
   10 -> 11-50 employees
   50 -> 51-200 employees
  200 -> 201-500 employees
  500 -> 501-1,000 employees
 1000 -> 1,001-5,000 employees
 5000 -> 5,001-10,000 employees
10000 -> 10,001+ employees

3. Configure credentials

Create .env:

CLAY_API_KEY=

Keep .env out of version control.

4. Implement the script

The shared helper goes in src/clay.ts. It wraps fetch, reads the balance and polls a routine run:

const BASE = "https://api.clay.com/public/v0";

function apiKey(): string {
  const key = process.env.CLAY_API_KEY;
  if (!key) throw new Error("CLAY_API_KEY is not set");
  return key;
}

export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method: init.method ?? "GET",
    headers: { "clay-api-key": apiKey(), "content-type": "application/json" },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`${res.status} ${path}: ${text}`);
  return JSON.parse(text) as T;
}

export interface RunItem {
  id: string;
  status: string;
  result?: Record<string, unknown>;
}

export interface RunResults {
  routine_run_id: string;
  status: string;
  total: number;
  finished: number;
  data?: RunItem[];
  cursor?: string;
}

// The results endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain.
export async function allResults(runId: string): Promise<RunResults> {
  let page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100`);
  const rows = [...(page.data ?? [])];
  while (page.cursor) {
    page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
    rows.push(...(page.data ?? []));
  }
  return { ...page, data: rows };
}

export async function balance(): Promise<number> {
  return (await clay<{ balance: number }>("/credits/balance")).balance;
}

export async function runRoutine(
  routineId: string,
  rows: { id: string; inputs: Record<string, unknown> }[],
  timeoutMs = 600_000,
): Promise<RunResults> {
  const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, {
    method: "POST",
    body: { items: rows },
  });
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await sleep(5000);
    const results = await allResults(started.routine_run_id);
    if (results.status !== "in_progress") return results;
  }
  throw new Error(`run ${started.routine_run_id} still in progress after ${timeoutMs / 1000} seconds`);
}

export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Save the script as src/lookalike.ts:

import { balance, clay, runRoutine, sleep } from "./clay";

const ENRICH_COMPANY = "function:t_0tjqm9uSZcH4rf4MDrK";
const seeds = ["stripe.com", "linear.app", "notion.so"];
const LIMIT = 10;

// The `sizes` filter takes the upper bound of the bucket below the one you want.
const SIZE_VALUE: Record<string, string> = {
  "Self-employed": "1",
  "2-10 employees": "2",
  "11-50 employees": "10",
  "51-200 employees": "50",
  "201-500 employees": "200",
  "501-1,000 employees": "500",
  "1,001-5,000 employees": "1000",
  "5,001-10,000 employees": "5000",
  "10,001+ employees": "10000",
};

interface Enriched {
  domain: string;
  industry: string;
  size: string;
  country: string;
}

interface SearchRow {
  name: string;
  domain: string;
  size: string;
  industry: string;
  location: string;
}

function mostCommon(values: string[]): string {
  const counts = new Map<string, number>();
  for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
}

async function main() {
  const before = await balance();

  const results = await runRoutine(
    ENRICH_COMPANY,
    seeds.map((s, i) => ({ id: String(i), inputs: { "Company Identifier": s } })),
  );
  const enriched: Enriched[] = (results.data ?? [])
    .filter((r) => r.status === "complete" && r.result?.["Enrich Company"])
    .map((r) => r.result!["Enrich Company"] as Enriched);
  if (enriched.length === 0) throw new Error("No seed company could be enriched");

  const industry = mostCommon(enriched.map((e) => e.industry));
  const sizeValues = [...new Set(enriched.map((e) => SIZE_VALUE[e.size]).filter(Boolean))];
  const countryCodeToName: Record<string, string> = { US: "United States" };
  const countries = [...new Set(enriched.map((e) => countryCodeToName[e.country]).filter(Boolean))];

  const filters: Record<string, string[]> = { industries: [industry], sizes: sizeValues };
  if (countries.length > 0) filters.country_names = countries;
  console.log(JSON.stringify({ seeds: enriched.map((e) => ({ domain: e.domain, industry: e.industry, size: e.size })), filters }));

  const { search_id } = await clay<{ search_id: string }>("/search/filters-mode", {
    method: "POST",
    body: { source_type: "companies", filters },
  });
  const page = await clay<{ data: SearchRow[]; has_more: boolean; period_quota?: { used: number; remaining: number } }>(
    `/search/filters-mode/${search_id}/run`,
    { method: "POST", body: { limit: LIMIT + seeds.length } },
  );

  const seedDomains = new Set([...seeds, ...enriched.map((e) => e.domain)]);
  const lookalikes = page.data.filter((c) => !seedDomains.has(c.domain)).slice(0, LIMIT);
  for (const c of lookalikes) console.log(JSON.stringify({ name: c.name, domain: c.domain, size: c.size, industry: c.industry }));

  await sleep(5000);
  const after = await balance();
  console.log(JSON.stringify({ candidates: lookalikes.length, hasMore: page.has_more, creditsSpent: before - after }));
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : err);
  process.exit(1);
});

5. Run

npx tsc
node --env-file=.env dist/lookalike.js

6. Verify the result

Output from a live run on 2026-10-12 in workspace 1322578:

{"seeds":[{"domain":"stripe.com","industry":"Technology, Information and Internet","size":"5,001-10,000 employees"},{"domain":"linear.app","industry":"Software Development","size":"51-200 employees"},{"domain":"notion.com","industry":"Software Development","size":"501-1,000 employees"}],"filters":{"industries":["Software Development"],"sizes":["5000","50","500"],"country_names":["United States"]}}
{"name":"GitHub","domain":"github.com","size":"501-1,000 employees","industry":"Software Development"}
{"name":"Airbnb","domain":"airbnb.com","size":"5,001-10,000 employees","industry":"Software Development"}
{"name":"Upwork","domain":"upwork.com","size":"501-1,000 employees","industry":"Software Development"}
{"name":"TechCrunch","domain":"techcrunch.com","size":"51-200 employees","industry":"Software Development"}
{"name":"Entrepreneur Media","domain":"entrepreneur.com","size":"51-200 employees","industry":"Software Development"}
{"name":"HubSpot","domain":"hubs.ly","size":"5,001-10,000 employees","industry":"Software Development"}
{"name":"Fiverr","domain":"fiverr.com","size":"501-1,000 employees","industry":"Software Development"}
{"name":"Twitter","domain":"twitter.com","size":"5,001-10,000 employees","industry":"Software Development"}
{"name":"Snowflake","domain":"snowflake.com","size":"5,001-10,000 employees","industry":"Software Development"}
{"name":"Databricks","domain":"databricks.com","size":"5,001-10,000 employees","industry":"Software Development"}
{"candidates":10,"hasMore":true,"creditsSpent":3}

The check is a set of properties, not literal values: three seeds were enriched, the filters contain one industry and at least one size bucket, no seed domain appears in the candidates, candidates is at most 10, and creditsSpent equals the number of seeds. The company names above are an illustration of what the search returned that day.

How it works

The enrichment step turns each customer into three attributes: industry, size bucket, and country. The script takes the most common industry across the seeds and the union of their size buckets, which keeps one outlier from narrowing the search to nothing. The search is a normal filters-mode search, so everything in it is an AND across fields and an OR within one field.

Seeds are removed from the results by comparing against two sets of domains, the ones you typed and the ones Enrich returned. Without the second set, a seed whose canonical domain differs from the typed one would appear as its own lookalike.

Common issues

Industry labels are coarse

The industry filter matches a broad label, so the candidates include media and marketplace companies that share the label with software companies. The run above returned TechCrunch and Entrepreneur Media under Software Development. The search also accepts description_keywords, which matches words in the company description. This example does not use it, so it is the field to try first when the candidates are too broad.

Seeds can disagree

Stripe's industry was Technology, Information and Internet, while Notion and Linear were Software Development. The script uses the most common value, so Stripe contributes only its size and country. If the seeds are meant to describe more than one segment, run the script once per segment.

A returned domain can be a redirect host

The search returned HubSpot with the domain hubs.ly, which is a link-shortener host. Check the domain before passing candidates to a routine that takes a domain as input.

Searches draw on a separate quota

Searching spends no credits, but every returned record counts against period_quota. The workspace used here reported a limit of 10,000,000, and the limit depends on the plan.

Next steps


verification:
  status: verified
  tested_at: "2026-10-12"
  product_version: "public/v0"
  command: "npx tsc && node --env-file=.env dist/lookalike.js"
  expected_result: "Three seeds enriched, filters derived, up to 10 candidates printed with no seed domains among them, and creditsSpent equal to 3."