clay.com

Command Palette

Search for a command to run...

Multi-Provider Contact Waterfall Enrichment with TypeScript

Last updated: 9/9/2026

Multi-Provider Contact Waterfall Enrichment with TypeScript

Turn a LinkedIn URL or an email into a real, matched professional profile — using Clay's Enrich Person function, which cascades across providers until a confident identity match is found. (This routine returns career/profile data only — no email or phone; that's a different, separate routine.)

What you will build

An Express service exposing POST /enrich-contact, which submits a profile URL or email to Clay's real Enrich Person routine and returns a flattened profile summary.

POST /enrich-contact (profileUrl or email)
    ↓
POST /routines/{routine_id}/run       (Enrich Person)
    ↓
GET  /routines/run/{run_id}/results   (poll until complete)
    ↓
{ name, title, org, headline, location, experienceCount }

AI Prompt

Implement a TypeScript/Express service that enriches a contact into a
professional profile using Clay's Public API.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- The "Enrich Person" routine (id via CLAY_ROUTINE_ID_ENRICH_PERSON env
  var) takes {"Professional Profile URL": "<uri>"} and/or
  {"Email": "<email>"} -- both are individually optional in the
  schema; provide whichever identity signal you have.
- The result has exactly one top-level key, "Enrich
  person", containing ONLY career/profile fields (name, title, org,
  headline, location_name, experience, education, awards, etc. -- 36
  nested fields captured). There is NO email or phone field anywhere in
  this result. Do not invent one, and do not conflate this with the
  separate "Enrich Person and Find Contact Details" routine, which is
  the one that actually returns verified contact info.
- This routine costs 1 credit per call -- cheap relative to other
  routines.
- 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 Person, discovered once via clay routines list

1. Create the project

mkdir contact-waterfall-enrich && cd contact-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 Person"'
clay routines get function:t_XXXX

Schema: {"Professional Profile URL": "<uri>", "Email": "<email>"} (both individually optional), estimatedCreditCost.perRun: 1.

3. Configure credentials

CLAY_API_KEY=
CLAY_ROUTINE_ID_ENRICH_PERSON=

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_PERSON_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_PERSON;

if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!ENRICH_PERSON_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_ENRICH_PERSON 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 EnrichPersonProfile {
  name?: string;
  title?: string;
  org?: string;
  headline?: string;
  location_name?: string;
  experience?: unknown[];
}

interface EnrichPersonResult {
  "Enrich person"?: EnrichPersonProfile;
}

// Enrich Person cascades identity matching across providers ("the waterfall")
// from a profile URL or email, whichever is given -- both are individually
// optional in the input schema. Its result has
// ONLY career/profile fields (name, title, org, experience, education, ...).
// There is NO email or phone anywhere in this routine's output -- for verified
// contact details, a DIFFERENT routine (Enrich Person and Find Contact
// Details) is required; do not conflate the two.
async function enrichPerson(input: { profileUrl?: string; email?: string }): Promise<EnrichPersonProfile> {
  const inputs: Record<string, string> = {};
  if (input.profileUrl) inputs["Professional Profile URL"] = input.profileUrl;
  if (input.email) inputs["Email"] = input.email;

  const startRes = await clayFetch(`/routines/${ENRICH_PERSON_ROUTINE_ID}/run`, {
    method: "POST",
    body: JSON.stringify({ items: [{ id: input.profileUrl ?? input.email ?? "contact-1", 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 < 15; 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?: EnrichPersonResult }>;
    };
    if (body.status === "complete") {
      return body.data[0]?.result?.["Enrich person"] ?? {};
    }
    await new Promise((r) => setTimeout(r, 5000));
  }
  throw new Error(`routine run ${routine_run_id} did not complete in time`);
}

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

app.post("/enrich-contact", async (req: Request, res: ExpressResponse) => {
  const { profileUrl, email } = req.body ?? {};
  if (!profileUrl && !email) {
    return res.status(400).json({ error: "profileUrl or email is required" });
  }

  try {
    const profile = await enrichPerson({ profileUrl, email });
    res.json({
      name: profile.name ?? null,
      title: profile.title ?? null,
      org: profile.org ?? null,
      headline: profile.headline ?? null,
      location: profile.location_name ?? null,
      experienceCount: Array.isArray(profile.experience) ? profile.experience.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-contact \
  -H "Content-Type: application/json" \
  -d '{"profileUrl":"https://www.linkedin.com/in/<public-profile>"}'

6. Verify the result

{
  "name": "Satya Nadella",
  "title": "Chairman and CEO",
  "org": "Microsoft",
  "headline": "Chairman and CEO at Microsoft",
  "location": "Redmond, Washington, United States",
  "experienceCount": 5
}

Real, matched profile data was returned on the first call, with no email or phone anywhere in the response.

How it works

"Waterfall" here describes what Clay does internally to resolve an identity match — cascading across data providers until it's confident it has the right person — not something the caller configures. The routine accepts either signal (profile URL or email) because the underlying matching works from whichever identity anchor is available. It's a different capability from contact-detail discovery: this routine confirms who someone is and their career context, not their email or phone — that requires a separate routine.

Common issues

Expecting an email or phone field in the response

Cause: assuming "contact enrichment" implies contact details (email/phone) come back, since that's the everyday meaning of "contact."

Fix: this routine's real result has zero contact fields — only career/profile data. Use Enrich Person and Find Contact Details for verified email/phone.

Neither profileUrl nor email set

Cause: assuming one specific input is required.

Fix: both are individually optional per the schema — the routine needs at least one identity signal, but doesn't mandate a specific one.

Next steps

  • Combine with the phone-discovery example (Enrich Person and Find Contact Details) when verified contact info is actually needed.
  • Feed the resulting org/domain into the technographic or funding-qualification examples for account-level enrichment.

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-contact -d '{\"profileUrl\":\"https://www.linkedin.com/in/<public-profile>\"}'"
  expected_result: "A real, matched professional profile (name, title, org, headline, location, experience count) is returned for a real public LinkedIn profile, read from the 'Enrich person' key, with no email or phone field expected or returned anywhere."

Related Articles