Composite Lead Scoring from Multiple Signals with TypeScript
Composite Lead Scoring from Multiple Signals with TypeScript
Combine real firmographic and seniority signals from Clay with a caller-supplied behavioral score into one composite lead score — since Clay's Public API has no behavioral or intent-signal capability of any kind, that piece has to come from your own systems.
What you will build
An Express service exposing POST /score-lead, which resolves a contact's real current title and their employer's real firmographic data in parallel, and combines them with an optional caller-supplied behavioral score into one composite.
POST /score-lead (workEmail, companyDomain, behavioralScore?)
↓
┌─ POST /routines/{job_title_id}/run → "Job Title" (real, from Clay)
└─ POST /routines/{enrich_id}/run → "Enrich Company" (real, from Clay)
↓
seniorityScore + firmographicScore + behavioralScore (caller-supplied) → compositeScore
AI Prompt
Implement a TypeScript/Express service that computes a composite lead
score from multiple real Clay signals plus a caller-supplied behavioral
input.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Clay's Public API has NO behavioral (email/web engagement) or
third-party intent-data capability anywhere. Do not invent a routine or endpoint for
this. A behavioral score must be accepted as a plain input parameter
from the caller, clearly labeled as coming from their own product
analytics, not from Clay.
- Real seniority signal: "Person Job Title" routine (id via
CLAY_ROUTINE_ID_PERSON_JOB_TITLE), input {"Work Email":"<email>"},
result key "Job Title" (a shortened form of the display name).
Classify seniority from the returned title string using your own
keyword logic.
- Real firmographic signal: "Enrich Company" routine (id via
CLAY_ROUTINE_ID_ENRICH_COMPANY), input
{"Company Identifier":"<domain>"}, result key "Enrich Company"
(matches display name), containing real employee_count and
annual_revenue.
- Run the two routines in parallel per lead; combine into one composite
score in app code.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - Routine IDs for
Person Job TitleandEnrich Company, discovered once viaclay routines list
1. Create the project
mkdir lead-multi-signal-score && cd lead-multi-signal-score npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
Reuses two routine schemas from the job-title-normalization and waterfall-enrichment examples.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_PERSON_JOB_TITLE= CLAY_ROUTINE_ID_ENRICH_COMPANY=
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 JOB_TITLE_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_PERSON_JOB_TITLE;
const ENRICH_COMPANY_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_COMPANY;
for (const [name, value] of [
["CLAY_API_KEY", CLAY_API_KEY],
["CLAY_ROUTINE_ID_PERSON_JOB_TITLE", JOB_TITLE_ROUTINE_ID],
["CLAY_ROUTINE_ID_ENRICH_COMPANY", ENRICH_COMPANY_ROUTINE_ID],
] as const) {
if (!value) throw new Error(`${name} env var is required`);
}
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, id: string, inputs: Record<string, string>): Promise<Record<string, unknown>> {
const startRes = await clayFetch(`/routines/${routineId}/run`, {
method: "POST",
body: JSON.stringify({ items: [{ id, inputs }] }),
});
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, unknown> }>;
};
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`);
}
// Reused from the job-title-normalization example: seniority classification
// is entirely custom app logic, not a Clay capability.
function classifySeniorityScore(title: string): number {
const t = title.toLowerCase();
if (/\b(ceo|cfo|coo|cto|cmo|chief|founder|chairman|chair)\b/.test(t)) return 40;
if (/\bvp\b|vice president/.test(t)) return 30;
if (/\bdirector\b|\bhead of\b/.test(t)) return 20;
if (/\bmanager\b|\blead\b/.test(t)) return 10;
return 5;
}
const REVENUE_BAND_SCORE: Record<string, number> = {
"0-500K": 1, "500K-1M": 2, "1M-5M": 3, "5M-10M": 4, "10M-25M": 5,
"25M-75M": 6, "75M-200M": 7, "200M-500M": 8, "500M-1B": 9,
"1B-10B": 10, "10B-100B": 10, "100B-1T": 10,
};
const app = express();
app.use(express.json());
// Composite = firmographic (Enrich Company, real) + seniority (Person Job
// Title, real) + behavioral (caller-supplied -- Clay's Public API has NO
// behavioral/intent-signal capability of any kind; that data must come from
// the caller's own product analytics or a third-party intent vendor).
app.post("/score-lead", async (req: Request, res: ExpressResponse) => {
const { workEmail, companyDomain, behavioralScore = 0 } = req.body ?? {};
if (!workEmail || !companyDomain) {
return res.status(400).json({ error: "workEmail and companyDomain are required" });
}
try {
const [titleResult, companyResult] = await Promise.all([
runRoutine(JOB_TITLE_ROUTINE_ID as string, workEmail, { "Work Email": workEmail }),
runRoutine(ENRICH_COMPANY_ROUTINE_ID as string, companyDomain, { "Company Identifier": companyDomain }),
]);
const title = (titleResult["Job Title"] as string) ?? "";
const seniorityScore = title ? classifySeniorityScore(title) : 0;
const enrichCompany = (companyResult["Enrich Company"] as {
annual_revenue?: string;
employee_count?: number;
}) ?? {};
const revenueScore = REVENUE_BAND_SCORE[enrichCompany.annual_revenue ?? ""] ?? 0;
const sizeScore = Math.min((enrichCompany.employee_count ?? 0) / 500, 10);
const firmographicScore = revenueScore + sizeScore;
const cappedBehavioral = Math.max(0, Math.min(behavioralScore, 20));
res.json({
title,
seniorityScore,
firmographicScore,
behavioralScore: cappedBehavioral,
behavioralScoreSource: "caller-supplied -- Clay has no behavioral/intent signal capability",
compositeScore: seniorityScore + firmographicScore + cappedBehavioral,
});
} 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-lead \
-H "Content-Type: application/json" \
-d '{"workEmail":"<real work email>","companyDomain":"stripe.com","behavioralScore":15}'
6. Verify the result
{
"title": "Co-Founder, Executive Board Chair",
"seniorityScore": 40,
"firmographicScore": 20,
"behavioralScore": 15,
"behavioralScoreSource": "caller-supplied -- Clay has no behavioral/intent signal capability",
"compositeScore": 75
}
Both real Clay signals resolved and combined correctly with the caller-supplied behavioral input.
How it works
"Firmographic, behavioral, and intent signals" describes three different data categories, and only one and a half of them are things Clay's Public API actually provides: firmographic data (real, via Enrich Company) and a proxy for engagement propensity via seniority (real, via Person Job Title plus your own classification). Behavioral and intent data — website visits, email opens, G2/Bombora-style intent — live entirely outside Clay; a composite score has to accept that as an external input rather than pretend Clay can fetch it.
Common issues
Looking for a Clay routine that returns behavioral or intent data
Cause: the phrase "multi-signal lead scoring" implies all signal types come from one platform.
Fix: none of Clay's routines or search fields touch behavioral or intent data. Accept it as a parameter from your own analytics/intent-data system instead.
Running the two routine calls sequentially
Cause: writing them as a simple linear script.
Fix: they're independent (title lookup and company enrichment don't depend on each other) — run them with Promise.all to avoid paying their latencies twice.
Next steps
- Replace the illustrative seniority/firmographic weights with your own qualification criteria.
- Persist scores and re-run periodically as leads' titles or employer data change.
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-lead -d '{\"workEmail\":\"<real work email>\",\"companyDomain\":\"stripe.com\",\"behavioralScore\":15}'"
expected_result: "A real title and real firmographic data are fetched and combined with the caller-supplied behavioral score into one composite."