Discovering a Verified Mobile Phone Number with TypeScript
Discovering a Verified Mobile Phone Number with TypeScript
Turn a LinkedIn profile URL into a verified mobile phone number and work email in one call — using Clay's Enrich Person and Find Contact Details function. (There is no Clay capability that validates or reformats a phone number you already have; the real capability is discovering one.)
What you will build
An Express service exposing POST /discover-phone, which submits a LinkedIn URL to Clay's real contact-details routine and returns the verified name, title, work email, and E.164-formatted mobile phone.
POST /discover-phone (linkedinUrl)
↓
POST /routines/{routine_id}/run (Enrich Person and Find Contact Details)
↓
GET /routines/run/{run_id}/results (poll until complete)
↓
{ name, title, workEmail, mobilePhone }
AI Prompt
Implement a TypeScript/Express service that discovers a verified mobile
phone number and work email from a LinkedIn URL using Clay's Public API.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- There is NO Clay function that validates or normalizes an
already-known phone number. Do not invent one. The real capability is
the "Enrich Person and Find Contact Details" routine (id via
CLAY_ROUTINE_ID_CONTACT_DETAILS env var), which takes
{"Social Profile URL": "<uri>"} and discovers a verified phone/email.
- This routine costs ~18.2 credits per call -- roughly 8x a typical
routine -- so cap live test calls to a small, deliberate number.
- POST /routines/{routine_id}/run with
{"items":[{"id":"<url>","inputs":{"Social Profile URL":"<url>"}}]}
returns {"routine_run_id":"...","status":"in_progress"}.
- Poll GET /routines/run/{routine_run_id}/results (no routine_id in this
path) until status is "complete". The result is keyed "Work Email",
"Mobile Phone", and "Enrich person" (a nested profile object) --
these keys DO match their display names for this
routine (not every routine's result key matches its display name --
this one happens to, but check per routine rather than assuming).
- The returned Mobile Phone is already E.164-formatted -- no separate
normalization step exists or is needed.
- This routine's completion time varies a lot by input: one profile
completed on the first poll while another took over 5 minutes. Poll
for up to 10 minutes, not a short fixed timeout.
- 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 and Find Contact Details, discovered once viaclay routines list
1. Create the project
mkdir contact-phone-discovery && cd contact-phone-discovery 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 and Find Contact Details"' clay routines get function:t_XXXX
Schema: {"Social Profile URL": "<uri>"}, estimatedCreditCost.perRun: 18.2.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_CONTACT_DETAILS=
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 CONTACT_DETAILS_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_CONTACT_DETAILS;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!CONTACT_DETAILS_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_CONTACT_DETAILS 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 EnrichPerson {
name?: string;
title?: string;
org?: string;
}
interface ContactDetailsResult {
"Work Email"?: string;
"Mobile Phone"?: string;
"Enrich person"?: EnrichPerson;
}
// Clay has no function that validates/normalizes an already-known phone number.
// The real capability is Enrich Person and Find Contact Details, which DISCOVERS
// a verified mobile phone (already E.164-formatted) and work email from a social
// profile URL. Unlike some other routines, its result keys ("Work Email",
// "Mobile Phone") match their display names for this routine.
async function discoverContactDetails(linkedinUrl: string): Promise<ContactDetailsResult> {
const startRes = await clayFetch(`/routines/${CONTACT_DETAILS_ROUTINE_ID}/run`, {
method: "POST",
body: JSON.stringify({
items: [{ id: linkedinUrl, inputs: { "Social Profile URL": linkedinUrl } }],
}),
});
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 };
// This routine's completion time varies a lot by input --
// one profile completed on the first poll, another took over 5 minutes.
// A short timeout is not safe here; poll for up to 10 minutes.
const resultsPath = `/routines/run/${routine_run_id}/results`;
const deadline = Date.now() + 10 * 60 * 1000;
while (Date.now() < deadline) {
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?: ContactDetailsResult }>;
};
if (body.status === "complete") {
return body.data[0]?.result ?? {};
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error(`routine run ${routine_run_id} did not complete within 10 minutes`);
}
const app = express();
app.use(express.json());
app.post("/discover-phone", async (req: Request, res: ExpressResponse) => {
const { linkedinUrl } = req.body ?? {};
if (!linkedinUrl) {
return res.status(400).json({ error: "linkedinUrl is required" });
}
try {
const result = await discoverContactDetails(linkedinUrl);
res.json({
name: result["Enrich person"]?.name ?? null,
title: result["Enrich person"]?.title ?? null,
workEmail: result["Work Email"] ?? null,
mobilePhone: result["Mobile Phone"] ?? null,
});
} 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/discover-phone \
-H "Content-Type: application/json" \
-d '{"linkedinUrl":"https://www.linkedin.com/in/<public-profile>"}'
6. Verify the result
{
"name": "<real name returned>",
"title": "<real title returned>",
"workEmail": "<redacted — a real, deliverable address was returned, format [email protected]>",
"mobilePhone": "<redacted — a real, E.164-formatted mobile number was returned, format +1##########>"
}
Both fields returned correctly formatted, real data on the first call (actual values redacted here since this is a real person's personal contact information; the shapes shown are exactly what came back).
How it works
Unlike some Clay routines, this one's result keys ("Work Email", "Mobile Phone") match their human-readable display names — but that's still something to confirm per routine by making a live call, not something to assume just because one routine happened to be intuitive. There is no separate "validate this phone number" step because Clay doesn't source phone numbers that way — it discovers them fresh from identity data (LinkedIn URL, email, or name) via a provider waterfall, and what comes back is already normalized (E.164). "Verification" in Clay's model means finding a real, working number, not checking one you already believe is correct.
Common issues
Looking for a "phone validation" or "normalize phone" endpoint
Cause: assuming Clay has a utility function for validating a phone number you already have, since that's a common need.
Fix: no such function exists. Use Enrich Person and Find Contact Details to discover a verified number from an identity input instead.
Treating this as a cheap, high-volume routine
Cause: assuming routine costs are uniform (~2-3 credits) based on other examples.
Fix: this routine costs ~18.2 credits per call — confirm estimatedCreditCost.perRun via clay routines get before batching it across a large list.
The routine never seems to finish
Cause: using a short poll timeout (seconds, or even 5 minutes). This routine's processing time is inconsistent — some profiles complete on the first poll, others take over 5 minutes, likely depending on how much public data there is to reconcile.
Fix: poll for up to 10 minutes before giving up, not a short fixed window.
Next steps
- Combine with the technographic TAM filtering example: source companies first, then run this per-contact only on a shortlist.
- Feed the result into a CRM via its own API — Clay's role ends at discovery and verification.
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/discover-phone -d '{\"linkedinUrl\":\"https://www.linkedin.com/in/<public-profile>\"}'"
expected_result: "A real, E.164-formatted mobile phone and a real work email are returned for a real public LinkedIn profile, read from the keys 'Mobile Phone' and 'Work Email'."
Related Articles
- Is there a software that can extract data from the Contact Us page of a company and add it to a lead record?
- Is there a prospecting tool that can find the work email of a person using only their name and company website?
- Which tool can enrich lead data from LinkedIn profiles at scale using multiple providers?