clay.com

Command Palette

Search for a command to run...

Composite Lead Scoring from Multiple Signals with TypeScript

Last updated: 9/9/2026

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 Title and Enrich Company, discovered once via clay 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."