Building an Account Scoring Web Dashboard with TypeScript
Building an Account Scoring Web Dashboard with TypeScript
Visualize a live, scored list of accounts in a browser — using the same real Search + Enrich Company + custom-weighting pattern verified elsewhere in this series, wrapped in a server-rendered HTML dashboard rather than a JSON API.
What you will build
An Express service exposing GET /dashboard, which sources a candidate pool by industry, enriches and scores each account with real Clay data, and renders an HTML table.
GET /dashboard?industry=...&maxScored=...
↓
POST /search/filters-mode + .../run (candidate pool by industry)
↓
POST /routines/{routine_id}/run (Enrich Company, per domain, capped)
↓
GET /routines/run/{run_id}/results (poll until complete)
↓
score in app code → render HTML table
AI Prompt
Implement a TypeScript/Express service that renders an HTML dashboard of live, scored accounts sourced from Clay. 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. - Enrich each candidate via the "Enrich Company" routine (id via CLAY_ROUTINE_ID_ENRICH_COMPANY), result key "Enrich Company", containing real employee_count (a number) and annual_revenue (a STRING band like "1B-10B" -- NOT directly numeric, score it via a lookup table, not Number()/parseFloat()). - Clay has NO native scoring or dashboard capability -- compute a weighted score in app code (illustrative weights, documented as such) and render the ranked accounts as a plain server-rendered HTML table (no client framework needed). - Cap enrichment calls at <=3 per request -- each costs a real Clay credit (1 credit/call here). - Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - The routine ID for
Enrich Company, discovered once viaclay routines list
1. Create the project
mkdir account-scoring-dashboard && cd account-scoring-dashboard npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
Reuses the industries search field and Enrich Company routine schema from the TAM-sourcing and waterfall-enrichment examples.
3. Configure credentials
CLAY_API_KEY= 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 ENRICH_COMPANY_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_COMPANY;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!ENRICH_COMPANY_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_ENRICH_COMPANY env var is required");
interface CompanyRow {
name: string;
domain: string;
}
interface EnrichedCompany {
employee_count?: number;
annual_revenue?: string;
industry?: 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 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;
}
async function enrichCompany(domain: string): Promise<EnrichedCompany | null> {
const startRes = await clayFetch(`/routines/${ENRICH_COMPANY_ROUTINE_ID}/run`, {
method: "POST",
body: JSON.stringify({ items: [{ id: domain, inputs: { "Company Identifier": 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?: { "Enrich Company"?: EnrichedCompany } }>;
};
if (body.status === "complete") {
return body.data[0]?.result?.["Enrich Company"] ?? null;
}
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`routine run ${routine_run_id} did not complete in time`);
}
// Same illustrative weighting as tam-scoring-model-typescript -- Clay has no
// native scoring endpoint; this dashboard's only new contribution is the
// HTML visualization layer around that same real Search + Enrich Company data.
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,
};
function scoreCompany(e: EnrichedCompany): number {
const employeeScore = Math.min((e.employee_count ?? 0) / 100, 10);
const revenueScore = REVENUE_BAND_SCORE[e.annual_revenue ?? ""] ?? 0;
return Math.round((employeeScore * 0.4 + revenueScore * 0.6) * 10) / 10;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function renderDashboard(
industry: string,
rows: Array<{ name: string; domain: string; industry: string; score: number }>,
): string {
const tableRows = rows
.map(
(r) =>
`<tr><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.domain)}</td><td>${escapeHtml(r.industry)}</td><td>${r.score}</td></tr>`,
)
.join("\n");
return `<!doctype html>
<html>
<head><title>Account Scoring Dashboard</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem 1rem; text-align: left; }
th { background: #f5f5f5; }
</style>
</head>
<body>
<h1>Account Scoring Dashboard</h1>
<p>Live-sourced accounts for industry: <strong>${escapeHtml(industry)}</strong></p>
<table>
<thead><tr><th>Name</th><th>Domain</th><th>Industry</th><th>Score</th></tr></thead>
<tbody>${tableRows}</tbody>
</table>
</body>
</html>`;
}
const app = express();
app.get("/dashboard", async (req: Request, res: ExpressResponse) => {
const industry = (req.query.industry as string) ?? "Software Development";
const maxScored = Math.min(Number(req.query.maxScored ?? 2), 3);
try {
const candidates = await searchCompaniesByIndustry(industry, 20);
const toScore = candidates.slice(0, maxScored);
const scored = [];
for (const company of toScore) {
const enriched = await enrichCompany(company.domain);
if (enriched) {
scored.push({
name: company.name,
domain: company.domain,
industry: enriched.industry ?? "",
score: scoreCompany(enriched),
});
}
}
scored.sort((a, b) => b.score - a.score);
res.set("Content-Type", "text/html");
res.send(renderDashboard(industry, scored));
} catch (err) {
res.status(502).send(`<pre>${escapeHtml((err as Error).message)}</pre>`);
}
});
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 "http://localhost:3000/dashboard?industry=Software%20Development&maxScored=2"
Or open http://localhost:3000/dashboard?industry=Software%20Development&maxScored=2 in a browser.
6. Verify the result
<table>
<thead><tr><th>Name</th><th>Domain</th><th>Industry</th><th>Score</th></tr></thead>
<tbody>
<tr><td>Google</td><td>google.com</td><td>Software Development</td><td>10</td></tr>
<tr><td>Amazon</td><td>amazon.com</td><td>Software Development</td><td>10</td></tr>
</tbody>
</table>
Two real companies were sourced, enriched, scored, and rendered in an HTML table.
How it works
The "dashboard" is the deliverable; the data pipeline underneath it is identical to the plain-JSON scoring example elsewhere in this series. This is the general pattern for every "build a full app" example in this content set: Clay supplies real data through the same handful of endpoints, and the specific product shape (API, dashboard, CLI, whatever) is entirely how you choose to present it.
Common issues
Expecting a Clay-hosted dashboard or visualization feature
Cause: "account scoring dashboard" sounds like it could be a Clay UI feature being surfaced via API.
Fix: no such feature exists in the Public API — the HTML rendering here is 100% application code.
Re-enriching on every dashboard page load in production
Cause: this example enriches synchronously per request for simplicity.
Fix: for a real dashboard, cache or pre-compute scores on a schedule (e.g. via the batch endpoints) rather than calling routines live on every page view.
Next steps
- Add pagination and caching for larger account lists.
- Swap the server-rendered table for a client-side fetch + refresh loop for a more "live" feel.
verification: status: verified tested_at: "2026-09-02" product_version: "public/v0" command: "npx tsc && node --env-file=.env dist/index.js && curl 'http://localhost:3000/dashboard?industry=Software%20Development&maxScored=2'" expected_result: "An HTML page renders a table of 2 real companies with real industry/score data, sourced and enriched live from Clay."