Multi-Provider Contact Waterfall Enrichment with TypeScript
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 viaclay 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."