clay.com

Command Palette

Search for a command to run...

Building an Account Scoring Web Dashboard with TypeScript

Last updated: 9/9/2026

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 via clay 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

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."

Related Articles