clay.com

Command Palette

Search for a command to run...

Searching First and Enriching Only the Matching Rows over REST with TypeScript

Last updated: 10/20/2026

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

Searching First and Enriching Only the Matching Rows over REST with TypeScript

A filters-mode company search returns domains, size, revenue band and funding for each company, and the calls that run it did not move the credit balance beyond what the workspace was spending anyway. This example runs one search of 15 companies, applies a match rule to the rows in the script, and enriches only the 5 rows that matched.

What you will build

A script that searches for US software companies of 51 to 200 employees, keeps those in a revenue band with known funding, and sends only those domains to Company Employee Count. It reads the balance around each step, and it also tries a rule that matches nothing to confirm that no run starts.

GET  /credits/balance      (idle window, no requests)
POST /search/filters-mode             -> search_id
POST /search/filters-mode/{id}/run    -> data[15], has_more, period_quota
GET  /credits/balance      (search step)
    ↓
match rule applied in the script      -> 5 of 15 rows
    ↓
POST /routines/{Employee Count}/run   (5 rows)  -> poll -> read rows
GET  /credits/balance      (enrich step)
    ↓
rule that matches nothing             -> no request, no run

AI Prompt

Implement a TypeScript script that searches Clay for companies and enriches only the rows that match a rule.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Create a search with POST /search/filters-mode and body { source_type: "companies", filters: { industries: ["Software Development"], sizes: ["50"], country_names: ["United States"] } }. The response is { search_id }. The sizes filter takes the upper bound of the bucket below the one wanted, so "50" returns companies labelled "51-200 employees". An unrecognised key returns 400 'Invalid search filters: (root): Unrecognized key: "bogus"', and a value outside an enum, such as sizes "51-200", returns 400 listing the allowed values.
- Run it with POST /search/filters-mode/{search_id}/run and body { limit: 15 }. The response has data, has_more and period_quota { limit, used, remaining, resets_at }. Each row has name, description, type, size, country, domain, linkedin_url, location, industry, annual_revenue and total_funding_amount_range_usd. A search consumes search quota, and period_quota.used rises by the number of rows returned.
- Match rule: the domain is a valid hostname, annual_revenue is one of "10M-25M", "25M-75M" or "75M-200M", and total_funding_amount_range_usd is anything other than "Funding unknown". Apply it in the script. Enrich only matching rows, using function:t_0tjqm9ruMvztzu9wbbw (Company Employee Count) with input "Company Domain" and result key "Employee Count", a string. The observed cost is 1 credit per row.
- Poll the run with GET /routines/run/{id}/results?limit=1, then read all rows with limit=100 and the cursor. Match rows to inputs by id.
- Other jobs share the workspace, so the balance can fall while the script is idle. Read an idle window of 10 seconds first and print it next to the search and enrichment deltas. Treat the search step as free when its balance change is smaller than a third of the rows it returned.
- Run a second rule that matches no rows and check that it starts no run.
- Print how many rows a no-filter enrichment would have covered. Do not run it.
- Exit 1 if any check fails. Run the verification step below before finishing.

Prerequisites

  • Node.js 18+ and TypeScript
  • A Clay Public API key (clay_scoped_...)
  • About 6 credits, plus search quota (15 rows per run of the script)

1. Create the project

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

The tsconfig.json below names the node types by hand, since TypeScript 6 and later no longer loads @types packages on its own:

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

2. Discover the real schema

List the filter names, and see how the API answers a value outside an enum:

BASE=https://api.clay.com/public/v0
curl -s -H "clay-api-key: $CLAY_API_KEY" "$BASE/search/filters-mode/fields?source_type=companies" | jq -r '[.fields[] | .name] | join(", ")'
curl -s -w ' [%{http_code}]\n' -X POST -H "clay-api-key: $CLAY_API_KEY" -H "content-type: application/json" \
  "$BASE/search/filters-mode" -d '{"source_type":"companies","filters":{"sizes":["51-200"]}}'

Output:

annual_revenues, country_names, country_names_exclude, derived_business_types, derived_industries, derived_revenue_streams, derived_subindustries, derived_subindustries_exclude, description_keywords, description_keywords_exclude, funding_amounts, include_company_identifiers, industries, industries_exclude, location_cities_exclude, location_cities_include, location_headquarters_only, location_postal_codes_exclude, location_postal_codes_include, location_regions_exclude, location_regions_include, location_states_exclude, location_states_include, locations, locations_exclude, maximum_member_count, minimum_follower_count, minimum_member_count, sizes, types
{"message":"Invalid search filters: sizes.0: Invalid option: expected one of \"1\"|\"2\"|\"10\"|\"50\"|\"200\"|\"500\"|\"1000\"|\"5000\"|\"10000\". Call GET /search/filters-mode/fields to see valid fields and values."} [400]

3. Configure credentials

Create .env:

CLAY_API_KEY=

Keep .env out of version control.

4. Implement the script

src/clay.ts has the fetch wrapper, the balance read, and a function that runs a routine inline and reads every result page:

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

export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
  const key = process.env.CLAY_API_KEY;
  if (!key) throw new Error("CLAY_API_KEY is not set");
  const res = await fetch(`${BASE}${path}`, {
    method: init.method ?? "GET",
    headers: { "clay-api-key": key, "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 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

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

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

export interface RunPage {
  status: string;
  total: number;
  finished: number;
  data?: RunRow[];
  cursor?: string;
}

// Starts an inline run and reads every page of the finished result.
export async function runRoutine(routineId: string, items: { id: string; inputs: Record<string, unknown> }[]): Promise<RunRow[]> {
  const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, { method: "POST", body: { items } });
  const path = `/routines/run/${started.routine_run_id}/results`;
  for (let i = 0; i < 100; i++) {
    const head = await clay<RunPage>(`${path}?limit=1`);
    if (head.status !== "in_progress") break;
    await sleep(3000);
  }
  const rows: RunRow[] = [];
  let page = await clay<RunPage>(`${path}?limit=100`);
  rows.push(...(page.data ?? []));
  while (page.cursor) {
    page = await clay<RunPage>(`${path}?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
    rows.push(...(page.data ?? []));
  }
  return rows;
}

src/search-then-enrich.ts holds the search, the match rule and the checks:

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

const EMPLOYEE_COUNT = "function:t_0tjqm9ruMvztzu9wbbw";

interface Company {
  name: string;
  domain: string | null;
  annual_revenue: string | null;
  total_funding_amount_range_usd: string | null;
}

interface SearchRun {
  data: Company[];
  has_more: boolean;
  period_quota?: { used: number; remaining: number };
}

// The match rule is applied to the search rows in this process, before any credit is spent.
type Rule = { name: string; revenueBands: string[]; requireKnownFunding: boolean };
const growthStage: Rule = { name: "growth stage", revenueBands: ["10M-25M", "25M-75M", "75M-200M"], requireKnownFunding: true };
const impossible: Rule = { name: "revenue above 100B", revenueBands: ["100B-1T"], requireKnownFunding: true };

const isHostname = (d: string | null): d is string => !!d && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(d);

function matches(c: Company, rule: Rule): boolean {
  return (
    isHostname(c.domain) &&
    rule.revenueBands.includes(c.annual_revenue ?? "") &&
    (!rule.requireKnownFunding || (c.total_funding_amount_range_usd ?? "Funding unknown") !== "Funding unknown")
  );
}

let runsStarted = 0;

async function enrichMatches(rows: Company[], rule: Rule) {
  const hits = rows.filter((c) => matches(c, rule));
  if (hits.length === 0) return { hits, enriched: [] as { name: string; employeeCount: string | null }[], spent: 0 };
  const before = await balance();
  runsStarted++;
  const out = await runRoutine(
    EMPLOYEE_COUNT,
    hits.map((c, i) => ({ id: `m${i}`, inputs: { "Company Domain": c.domain } })),
  );
  await sleep(5000);
  const spent = before - (await balance());
  const enriched = hits.map((c, i) => {
    const row = out.find((r) => r.id === `m${i}`);
    const v = row?.result?.["Employee Count"];
    return { name: c.name, employeeCount: typeof v === "string" && v !== "" ? v : null };
  });
  return { hits, enriched, spent };
}

async function main() {
  // Other jobs share this workspace, so the balance can move while this script does nothing.
  // An idle window with no requests gives a reference for that background movement.
  const idleStart = await balance();
  await sleep(10000);
  const idleDrift = idleStart - (await balance());
  console.log(JSON.stringify({ step: "idle window, 10 seconds, no requests", balanceMovement: idleDrift }));

  const balanceBeforeSearch = await balance();
  const created = await clay<{ search_id: string }>("/search/filters-mode", {
    method: "POST",
    body: { source_type: "companies", filters: { industries: ["Software Development"], sizes: ["50"], country_names: ["United States"] } },
  });
  const search = await clay<SearchRun>(`/search/filters-mode/${created.search_id}/run`, { method: "POST", body: { limit: 15 } });
  await sleep(5000);
  const balanceAfterSearch = await balance();
  console.log(JSON.stringify({
    step: "search",
    rows: search.data.length,
    hasMore: search.has_more,
    quotaUsed: search.period_quota?.used,
    creditsSpentBySearch: balanceBeforeSearch - balanceAfterSearch,
  }));

  const first = await enrichMatches(search.data, growthStage);
  console.log(JSON.stringify({
    step: "enrich matches",
    rule: growthStage.name,
    searched: search.data.length,
    matched: first.hits.length,
    enrichedRows: first.enriched.length,
    creditsSpentByEnrichment: first.spent,
    wouldHaveEnrichedWithoutFilter: search.data.length,
  }));
  first.enriched.forEach((e) => console.log(JSON.stringify(e)));

  // Negative control: a rule that matches nothing must start no run.
  const startedBefore = runsStarted;
  const none = await enrichMatches(search.data, impossible);
  console.log(JSON.stringify({ step: "rule with no matches", matched: none.hits.length, runsStartedByThisStep: runsStarted - startedBefore, creditsSpent: none.spent }));

  // Properties, not literals: the search rows and the provider results change from day to day.
  const matchedNames = new Set(first.hits.map((c) => c.name));
  const ok =
    search.data.length > 0 &&
    first.hits.length > 0 && first.hits.length < search.data.length && // the fixture must contain both matches and non-matches
    first.hits.every((c) => matches(c, growthStage)) &&
    first.enriched.length === first.hits.length &&
    first.enriched.every((e) => matchedNames.has(e.name)) &&
    none.hits.length === 0 && runsStarted - startedBefore === 0 &&
    first.spent >= 1 &&
    // A per-row search charge would be about 15 credits. Background spend by other jobs can add a few.
    balanceBeforeSearch - balanceAfterSearch < search.data.length / 3;
  console.log(ok ? "verification passed" : "verification failed");
  process.exit(ok ? 0 : 1);
}

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

5. Run

npx tsc
node --env-file=.env dist/search-then-enrich.js; echo "exit=$?"

6. Verify the result

Output on 2026-10-20, workspace 1322578:

{"step":"idle window, 10 seconds, no requests","balanceMovement":1}
{"step":"search","rows":15,"hasMore":true,"quotaUsed":52520,"creditsSpentBySearch":0}
{"step":"enrich matches","rule":"growth stage","searched":15,"matched":5,"enrichedRows":5,"creditsSpentByEnrichment":6,"wouldHaveEnrichedWithoutFilter":15}
{"name":"Hugging Face","employeeCount":"1171"}
{"name":"Huzzle.com","employeeCount":"148"}
{"name":"Quartic","employeeCount":"70"}
{"name":"Lucidity","employeeCount":"126"}
{"name":"Darwix AI","employeeCount":"107"}
{"step":"rule with no matches","matched":0,"runsStartedByThisStep":0,"creditsSpent":0}
verification passed
exit=0

The checks are properties. The search returned rows. Some rows matched and some did not, which the fixture needs for the filter to mean anything: 5 of 15. Every enriched row satisfies the rule, the number of enriched rows equals the number of matches, and the second rule starts no run. The search step moved the balance by less than a third of its 15 rows, and the enrichment moved it by at least 1. The company names and employee counts are what the search and enrichment returned that day, shown as illustration.

Two figures need reading with the shared workspace in mind. The enrichment of 5 rows moved the balance by 6, and in an earlier run of the same script the search step moved it by 1 and the enrichment by 5. The workspace was also spending credits for other jobs: with the script making no requests, the balance fell by 1 in the 10-second window above, and in a separate 40-second window with no requests it fell by 1 at about 21 seconds and again at about 41 seconds. A one-credit difference in either direction is therefore inside the background movement. The per-row cost of 1 credit comes from the measurement in Showing the Estimated Cost Before an Enrichment over REST with TypeScript, where the smallest balance change over repeated runs was 1 credit per row.

The search quota behaves differently. In two consecutive 15-row searches made while building this example, period_quota.used went from 52415 to 52430. The quota counts rows returned, and this workspace reported a limit of 10000000.

How it works

The search and the enrichment are separate calls with separate meters. A filters-mode search returns up to limit rows and reports them against the period quota. A routine run is billed in credits per row. Because the search rows already carry the domain, revenue band and funding range, the script can decide which companies are worth a credit before sending any of them to a routine.

The match rule runs in the script, after the search, and sees every field of each row. It can combine conditions that a single search request does not combine, here a revenue band with a known funding range. The enrichment request then carries only the matching domains. Without the rule the same search would have produced 15 rows to enrich, which at 1 credit per row is the figure printed as wouldHaveEnrichedWithoutFilter. That comparison is arithmetic, and the 15-row enrichment was not run.

Common issues

The search is not free in the quota sense

A search does not change the credit balance beyond the workspace's background movement, but it consumes search quota, and a run reports it in period_quota. The plan limits are listed in Reading Search Quota with the Clay CLI. This example did not try to exhaust the quota.

Filter values are validated against fixed lists

sizes accepts 1, 2, 10, 50, 200, 500, 1000, 5000 and 10000, and a bucket label such as 51-200 returns 400 with the allowed values. A key that is not a filter name returns 400 with Unrecognized key. The fields endpoint lists the valid names and the allowed values for the enumerated ones.

Search rows are not routine inputs

Every row in this run had a domain that passed the hostname check, so the check removed nothing in this fixture. The check is still in the script, because a routine that takes a domain rejects an invalid hostname with 400 and would fail the whole request. This example did not receive a row with a missing or malformed domain.

A rule with no matches should not start a run

With the second rule, enrichMatches returned before any request. A run with an empty items array is rejected by the API with 400, as shown in Reconciling Submitted and Returned Rows over REST with TypeScript, so the guard also avoids an error.

Results differ from day to day

The set of 15 companies, their revenue bands and the employee counts are not fixed. The number of matching rows can differ between runs, and a run where every row matched or none did would fail the script's check that the fixture contains both. Change the rule or the filters when that happens.

Only the first page of the search was read

has_more was true. This example reads one page of 15 and does not page through the rest of the search.

Next steps


verification:
  status: verified
  tested_at: "2026-10-20"
  product_version: "public/v0"
  command: "npx tsc && node --env-file=.env dist/search-then-enrich.js"
  expected_result: "A 15-row search returns some rows that match the rule and some that do not, only the matching rows are enriched, a rule that matches nothing starts no run, the search step moves the balance by less than a third of its row count, and the script prints `verification passed` and exits 0."

Related Articles