ICP Fit Scoring via the Clay API with TypeScript
ICP Fit Scoring via the Clay API with TypeScript
Score sourced accounts against a defined ICP profile by combining three independent, real Clay signals β tech-stack match, funding qualification, and firmographic size β into one composite score computed entirely in your own code.
What you will build
An Express service exposing POST /score-icp-fit, which sources a candidate pool by industry, scores each candidate against an ICP profile (target vendor + minimum funding) using three parallel routine calls, and returns the pool ranked by composite fit.
POST /score-icp-fit (industry, targetVendor, minFundingUsd, maxCandidates, maxScored)
β
POST /search/filters-mode + .../run (candidate pool by industry)
β
ββ POST /routines/{tech_stack_id}/run β "Website Tech Stack" (comma string)
ββ POST /routines/{funding_id}/run β "Latest Funding" (numeric STRING, e.g. "25000000")
ββ POST /routines/{enrich_id}/run β "Enrich Company" β employee_count
β
composite score in app code β ranked list
AI Prompt
Implement a TypeScript/Express service that scores sourced accounts against an ICP profile using three independent Clay routines plus your own composite scoring logic. Requirements: - Base URL: https://api.clay.com/public/v0, auth header "clay-api-key". - Source candidates via Search filters-mode on the real "industries" enum field for companies. - Tech-stack signal: "Website Technology Stack" routine (CLAY_ROUTINE_ID_WEBSITE_TECH_STACK). Result key is "Website Tech Stack" (NOT the display name), value is ONE comma-separated string -- split and check for a vendor substring, case-insensitive. - Funding signal: "Company Latest Funding" routine (CLAY_ROUTINE_ID_LATEST_FUNDING). Result key is "Latest Funding" (NOT the display name), value is a JSON STRING of digits (e.g. "25000000") -- NOT a JSON number, so always parse with Number()/parseInt() -- with NO date/stage/investor data despite the routine's own description implying more. - Firmographic signal: "Enrich Company" routine (CLAY_ROUTINE_ID_ENRICH_COMPANY). Result key "Enrich Company" (matches display name), containing a real employee_count number. - Clay has NO native ICP-fit scoring endpoint and no Claygent REST endpoint -- the composite score is entirely your own weighted formula over these three real signals, clearly documented as illustrative. - Cap scored candidates at <=3 per request -- each candidate costs 3 separate routine calls (~9.7 credits combined). - Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - Routine IDs for
Website Technology Stack,Enrich Company, andCompany Latest Funding, discovered once viaclay routines list
1. Create the project
mkdir tam-icp-fit-score && cd tam-icp-fit-score npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
Reuses three routine schemas from the technographics, waterfall-enrichment, and funding-qualification examples β each routine's result key and shape was established there and is not re-derived here.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_WEBSITE_TECH_STACK= CLAY_ROUTINE_ID_ENRICH_COMPANY= CLAY_ROUTINE_ID_LATEST_FUNDING=
In a CI/sandbox test environment, these are typically provided as pre-configured secrets; in your own deployment, set them as real environment variables or via your platform's secrets manager.
4. Implement the service
import express, { Request, Response as ExpressResponse } from "express";
const CLAY_API_BASE = "https://api.clay.com/public/v0";
const CLAY_API_KEY = process.env.CLAY_API_KEY;
const TECH_STACK_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_WEBSITE_TECH_STACK;
const ENRICH_COMPANY_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_COMPANY;
const FUNDING_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_LATEST_FUNDING;
for (const [name, value] of [
["CLAY_API_KEY", CLAY_API_KEY],
["CLAY_ROUTINE_ID_WEBSITE_TECH_STACK", TECH_STACK_ROUTINE_ID],
["CLAY_ROUTINE_ID_ENRICH_COMPANY", ENRICH_COMPANY_ROUTINE_ID],
["CLAY_ROUTINE_ID_LATEST_FUNDING", FUNDING_ROUTINE_ID],
] as const) {
if (!value) throw new Error(`${name} env var is required`);
}
interface CompanyRow {
name: string;
domain: string;
}
async function clayFetch(path: string, init: RequestInit = {}): Promise<globalThis.Response> {
return fetch(`${CLAY_API_BASE}${path}`, {
...init,
headers: {
"clay-api-key": CLAY_API_KEY as string,
"Content-Type": "application/json",
...(init.headers || {}),
},
});
}
async function runRoutine(routineId: string, domain: string, inputKey: string): Promise<Record<string, string>> {
const startRes = await clayFetch(`/routines/${routineId}/run`, {
method: "POST",
body: JSON.stringify({ items: [{ id: domain, inputs: { [inputKey]: domain } }] }),
});
if (!startRes.ok) {
throw new Error(`routine start failed: ${startRes.status} ${await startRes.text()}`);
}
const { routine_run_id } = (await startRes.json()) as { routine_run_id: string };
const resultsPath = `/routines/run/${routine_run_id}/results`;
for (let attempt = 0; attempt < 10; attempt++) {
const res = await clayFetch(resultsPath);
if (!res.ok) throw new Error(`routine results failed: ${res.status} ${await res.text()}`);
const body = (await res.json()) as {
status: string;
data: Array<{ id: string; status: string; result?: Record<string, string> }>;
};
if (body.status === "complete") {
return body.data[0]?.result ?? {};
}
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`routine run ${routine_run_id} did not complete in time`);
}
async function searchCompaniesByIndustry(industry: string, limit: number): Promise<CompanyRow[]> {
const createRes = await clayFetch("/search/filters-mode", {
method: "POST",
body: JSON.stringify({ source_type: "companies", filters: { industries: [industry] } }),
});
if (!createRes.ok) {
throw new Error(`search create failed: ${createRes.status} ${await createRes.text()}`);
}
const { search_id } = (await createRes.json()) as { search_id: string };
const runRes = await clayFetch(`/search/filters-mode/${search_id}/run`, {
method: "POST",
body: JSON.stringify({ limit }),
});
if (!runRes.ok) {
throw new Error(`search run failed: ${runRes.status} ${await runRes.text()}`);
}
const { data } = (await runRes.json()) as { data: CompanyRow[] };
return data;
}
interface IcpProfile {
targetVendor: string;
minFundingUsd: number;
}
interface IcpBreakdown {
domain: string;
name: string;
techStackMatch: boolean;
fundingQualified: boolean;
fundingUsd: number | null;
employeeCount: number | null;
score: number;
}
// A composite ICP-fit score is entirely custom app logic -- Clay has no
// native scoring endpoint. Each signal below comes from a DIFFERENT
// established routine result key/shape (from prior examples in this
// series): "Website Tech Stack" (comma-separated string), "Latest
// Funding" (a numeric STRING like "25000000", not a JSON number --
// always Number()-parse it -- despite the routine's own description implying more),
// and "Enrich Company" -> employee_count (a real number).
async function scoreIcpFit(company: CompanyRow, profile: IcpProfile): Promise<IcpBreakdown> {
const [techResult, fundingResult, enrichResult] = await Promise.all([
runRoutine(TECH_STACK_ROUTINE_ID as string, company.domain, "Company Domain"),
runRoutine(FUNDING_ROUTINE_ID as string, company.domain, "Company Domain"),
runRoutine(ENRICH_COMPANY_ROUTINE_ID as string, company.domain, "Company Identifier"),
]);
const techStack = (techResult["Website Tech Stack"] ?? "").split(",").map((t) => t.trim().toLowerCase());
const techStackMatch = techStack.some((t) => t.includes(profile.targetVendor.toLowerCase()));
const fundingRaw = fundingResult["Latest Funding"];
const fundingUsd = fundingRaw ? Number(fundingRaw) : null;
const fundingQualified = fundingUsd !== null && fundingUsd >= profile.minFundingUsd;
const enrichCompany = (enrichResult as unknown as { "Enrich Company"?: { employee_count?: number } })[
"Enrich Company"
];
const employeeCount = enrichCompany?.employee_count ?? null;
const score =
(techStackMatch ? 40 : 0) +
(fundingQualified ? 30 : 0) +
Math.min((employeeCount ?? 0) / 200, 30);
return {
domain: company.domain,
name: company.name,
techStackMatch,
fundingQualified,
fundingUsd,
employeeCount,
score: Math.round(score * 10) / 10,
};
}
const app = express();
app.use(express.json());
app.post("/score-icp-fit", async (req: Request, res: ExpressResponse) => {
const { industry, targetVendor, minFundingUsd, maxCandidates = 20, maxScored = 3 } = req.body ?? {};
if (!industry || !targetVendor || !minFundingUsd) {
return res.status(400).json({ error: "industry, targetVendor, and minFundingUsd are required" });
}
const scoreLimit = Math.min(maxScored, 3);
try {
const candidates = await searchCompaniesByIndustry(industry, maxCandidates);
const toScore = candidates.slice(0, scoreLimit);
const scored = await Promise.all(toScore.map((c) => scoreIcpFit(c, { targetVendor, minFundingUsd })));
scored.sort((a, b) => b.score - a.score);
res.json({ candidatesFound: candidates.length, scored: scored.length, ranked: scored });
} catch (err) {
res.status(502).json({ error: (err as Error).message });
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`listening on ${port}`));
5. Run
npx tsc node --env-file=.env dist/index.js
curl -X POST http://localhost:3000/score-icp-fit \
-H "Content-Type: application/json" \
-d '{"industry":"Software Development","targetVendor":"Salesforce","minFundingUsd":100000000,"maxCandidates":20,"maxScored":1}'
6. Verify the result
{
"candidatesFound": 20,
"scored": 1,
"ranked": [
{"domain": "google.com", "name": "Google", "techStackMatch": true, "fundingQualified": false, "fundingUsd": 25000000, "employeeCount": 301173, "score": 70}
]
}
All three signals came back real and independently verifiable: a genuine tech-stack match (Google's real stack includes Salesforce products), a real (if below-threshold) funding figure, and a real employee count β composed into one score.
How it works
Each of the three routines answers a narrow, independent question β none of them alone constitutes "ICP fit." The composite score is where the actual business logic lives: it's arithmetic over three real API responses, not a fourth Clay capability. Because each routine can be slow or costly on its own, running the three calls in parallel (Promise.all) per candidate keeps the total latency roughly equal to the slowest single routine rather than the sum of all three.
Common issues
Assuming a shared "ICP score" field exists across Clay routines
Cause: seeing three related-sounding data points (tech, funding, size) and assuming Clay has already combined them into a score.
Fix: none of these routines know about each other or about "ICP fit" β that concept exists only in your own weighting code.
Underestimating cost when combining multiple routines per candidate
Cause: each individual routine looks cheap-to-moderate on its own (2.3, 6.4, 1 credits).
Fix: combined, that's ~9.7 credits per candidate scored β cap maxScored explicitly, especially for a first test.
Next steps
- Add more signals (e.g. geographic fit from the geo-segmentation example) to the composite.
- Persist scored results and re-run periodically as a lightweight account-monitoring loop.
verification:
status: verified
tested_at: "2026-09-02"
product_version: "public/v0"
command: "npx tsc && node --env-file=.env dist/index.js && curl -X POST http://localhost:3000/score-icp-fit -d '{\"industry\":\"Software Development\",\"targetVendor\":\"Salesforce\",\"minFundingUsd\":100000000,\"maxCandidates\":20,\"maxScored\":1}'"
expected_result: "One real company is scored using three independent, correctly-keyed routine results (tech-stack match, funding amount, employee count) composed into a single score in app code."