clay.com

Command Palette

Search for a command to run...

Multi-Provider Waterfall Enrichment for a TAM List with TypeScript

Last updated: 9/9/2026

Multi-Provider Waterfall Enrichment for a TAM List with TypeScript

Maximize the match rate on a list of company identifiers — using Clay's Enrich Company function, which cascades across data providers internally for each identifier. (There is no provider-configuration input; "waterfall" describes what Clay does internally, not something the caller sets up.)

What you will build

An Express service exposing POST /enrich-tam, which enriches a batch of company identifiers (domain or LinkedIn URL) and reports which matched versus which didn't.

POST /enrich-tam (identifiers[], maxEnrichments)
    ↓
POST /routines/{routine_id}/run       (Enrich Company, per identifier, capped at 5)
    ↓
GET  /routines/run/{run_id}/results   (poll until complete)
    ↓
{ requested, matched[], unmatched[], matchRate }

AI Prompt

Implement a TypeScript/Express service that enriches a list of company
identifiers with waterfall enrichment using Clay's Public API.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- The "Enrich Company" routine (id via CLAY_ROUTINE_ID_ENRICH_COMPANY env
  var) takes a single field {"Company Identifier": "<domain, LinkedIn
  URL, or Sales Navigator ID>"}. There is NO provider-configuration
  field and no separate /providers endpoint -- do not invent one. The
  "multi-provider waterfall" is internal to Clay; the caller supplies
  one identifier per call, nothing more.
- The result key is "Enrich Company" (matches the
  routine's display name exactly here -- unlike some other routines,
  this one is not a display-name trap, but check per routine rather than
  assuming) and contains ~25 real firmographic fields (name, domain,
  size, industry, employee_count, annual_revenue, etc.).
- Cap enrichment calls at <=5 per request -- each costs real Clay
  credits (1 credit/call here, cheap but still real).
- 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 tam-waterfall-enrich && cd tam-waterfall-enrich
npm init -y
npm install express
npm install -D typescript @types/express @types/node
npx tsc --init

2. Discover the real schema

clay routines list | grep -B1 -A3 '"Enrich Company"'
clay routines get function:t_XXXX

Schema: {"Company Identifier": "<hostname>"}, estimatedCreditCost.perRun: 1.

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");

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 || {}),
    },
  });
}

interface EnrichedCompany {
  name?: string;
  domain?: string;
  industry?: string;
  employee_count?: string;
  annual_revenue?: string;
  country?: string;
}

// "Waterfall" here describes Clay's internal cascade across data providers for
// a single identifier -- there is NO provider-configuration input on this
// routine (a single field, "Company Identifier"). Maximizing match
// rate across a TAM list means calling this once per identifier and tracking
// which ones resolved, not configuring which providers get tried.
async function enrichCompany(identifier: string): Promise<EnrichedCompany | null> {
  const startRes = await clayFetch(`/routines/${ENRICH_COMPANY_ROUTINE_ID}/run`, {
    method: "POST",
    body: JSON.stringify({ items: [{ id: identifier, inputs: { "Company Identifier": identifier } }] }),
  });
  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`);
}

const app = express();
app.use(express.json());

app.post("/enrich-tam", async (req: Request, res: ExpressResponse) => {
  const { identifiers, maxEnrichments = 5 } = req.body ?? {};
  if (!Array.isArray(identifiers) || identifiers.length === 0) {
    return res.status(400).json({ error: "identifiers must be a non-empty array" });
  }
  const toEnrich = identifiers.slice(0, Math.min(maxEnrichments, 5));

  try {
    const matched: Array<{ identifier: string; company: EnrichedCompany }> = [];
    const unmatched: string[] = [];

    for (const identifier of toEnrich) {
      const company = await enrichCompany(identifier);
      if (company && company.name) {
        matched.push({ identifier, company });
      } else {
        unmatched.push(identifier);
      }
    }

    res.json({
      requested: toEnrich.length,
      matched,
      unmatched,
      matchRate: toEnrich.length ? matched.length / toEnrich.length : 0,
    });
  } 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/enrich-tam \
  -H "Content-Type: application/json" \
  -d '{"identifiers":["notion.so","linear.app"],"maxEnrichments":2}'

6. Verify the result

{
  "requested": 2,
  "matched": [
    {"identifier": "notion.so", "company": {"name": "Notion", "domain": "notion.com", "industry": "Software Development", "size": "501-1,000 employees", "country": "US"}},
    {"identifier": "linear.app", "company": {"name": "Linear", "...": "..."}}
  ],
  "unmatched": [],
  "matchRate": 1
}

Both real companies matched on the first call.

How it works

"Multi-provider waterfall" is an accurate description of what happens inside Clay when this routine runs, not a knob the API exposes. The caller's job is simply to supply the best identifier available (a LinkedIn URL resolves most reliably per the routine's own schema description) and track which identifiers in a batch came back with real data versus not — that match/no-match outcome per identifier is the actual observable "waterfall result" from the API's point of view.

Common issues

Looking for a way to configure or exclude specific data providers

Cause: assuming "multi-provider waterfall" implies a provider list the caller controls, since that's how the term is used in some other GTM tools.

Fix: no such control exists on this routine — it's a single opaque call per identifier; provider selection is entirely internal to Clay.

Treating an empty/partial result as an error

Cause: expecting every identifier to resolve.

Fix: track unmatched identifiers explicitly (missing name in the result) rather than throwing — a real TAM list will have some non-matches, and that's the actual "match rate" signal this pattern is meant to surface.

Next steps

  • Feed matched company domains into the technographic filtering or funding-qualification examples for further enrichment.
  • For larger lists, use /routines/{routine_id}/run-batch/start instead of looping single calls.

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/enrich-tam -d '{\"identifiers\":[\"notion.so\",\"linear.app\"],\"maxEnrichments\":2}'"
  expected_result: "Both real company identifiers resolve to real firmographic data (name, domain, industry, size), read from the 'Enrich Company' result key."