Re-Validating Stale Contacts in a CRM Export over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Re-Validating Stale Contacts in a CRM Export over REST with TypeScript
This example re-runs the Work Email routine only for contacts whose last verification is older than a cutoff, and classifies each one as unchanged, changed, or not found. In the run below, 3 stale rows cost 9 credits and 1 fresh row was skipped.
What you will build
A script that reads a contact export, selects the rows verified more than 180 days ago, runs Work Email over just those rows, and compares each returned address with the stored one. Addresses are masked in the output.
contacts (stored email, last_verified)
↓
keep rows where last_verified is older than 180 days
↓
POST /routines/{Work Email}/run -> routine_run_id
GET /routines/run/{id}/results (poll for up to 10 minutes)
↓
unchanged | changed | not_found, plus whether the domain matches
AI Prompt
Implement a TypeScript script that re-validates stale CRM contacts with the Clay Public API.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use the "Work Email" routine, function:t_0tjqmaaroo9mQFCBA5C. Required inputs: "Full Name", "Company Domain", "Company Name". The listed estimate is 3 credits per run.
- Run it with POST /routines/{id}/run and body { items: [{ id, inputs }] }, then poll GET /routines/run/{routine_run_id}/results until status is not "in_progress". Match rows by id, because results do not come back in input order.
- A found address is in result["Work Email"]. A row with no address still has status "complete" and an empty result object, so check for the key, not only the status.
- Run time varies. The same 3-row input finished in about 70 seconds in one run and took more than 3 minutes in another. Poll for up to 10 minutes instead of giving up at 2 minutes, because the credits are spent whether or not the script is still waiting.
- Only send rows whose last_verified date is more than 180 days old. Skip the rest, so they cost nothing.
- Classify each stale row as "unchanged" (same address, case-insensitive), "changed" (a different address), or "not_found" (no address returned). Also report whether the found address is on the contact's Company Domain.
- Do not print full addresses. Print only a masked form such as ***@domain.
- Read GET /credits/balance before and after, and report the credits spent.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and a local TypeScript install
- A Clay Public API key (
clay_scoped_...) - A credit balance above 9, since the sample sends three rows at 3 credits each
1. Create the project
mkdir clay-revalidate && cd clay-revalidate npm init -y npm install -D typescript @types/node mkdir src
Add a tsconfig.json with an explicit types entry. TypeScript 6 and later no longer picks up @types/node without it, and the script needs Node's globals:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
2. Discover the real schema
Read the routine's input schema once with the CLI, which needs a signed-in session (clay routines get returns auth_forbidden with only an API key). Without one, take the three required inputs from the AI prompt above:
clay routines get function:t_0tjqmaaroo9mQFCBA5C | jq -c '{required: .inputSchema.required, estimate: .estimatedCreditCost.perRun}'
{"required":["Full Name","Company Domain","Company Name"],"estimate":3}
3. Configure credentials
Create .env:
CLAY_API_KEY=
Keep .env out of version control.
4. Implement the script
The helper lives in src/clay.ts. Its polling window is 10 minutes by default:
const BASE = "https://api.clay.com/public/v0";
function apiKey(): string {
const key = process.env.CLAY_API_KEY;
if (!key) throw new Error("CLAY_API_KEY is not set");
return key;
}
export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: init.method ?? "GET",
headers: { "clay-api-key": apiKey(), "content-type": "application/json" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const text = await res.text();
if (!res.ok) throw new Error(`${res.status} ${path}: ${text}`);
return JSON.parse(text) as T;
}
export interface RunItem {
id: string;
status: string;
result?: Record<string, unknown>;
}
export interface RunResults {
routine_run_id: string;
status: string;
total: number;
finished: number;
data?: RunItem[];
cursor?: string;
}
// The results endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain.
export async function allResults(runId: string): Promise<RunResults> {
let page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100`);
const rows = [...(page.data ?? [])];
while (page.cursor) {
page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
rows.push(...(page.data ?? []));
}
return { ...page, data: rows };
}
export async function balance(): Promise<number> {
return (await clay<{ balance: number }>("/credits/balance")).balance;
}
export async function runRoutine(
routineId: string,
rows: { id: string; inputs: Record<string, unknown> }[],
timeoutMs = 600_000,
): Promise<RunResults> {
const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, {
method: "POST",
body: { items: rows },
});
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(5000);
const results = await allResults(started.routine_run_id);
if (results.status !== "in_progress") return results;
}
throw new Error(`run ${started.routine_run_id} still in progress after ${timeoutMs / 1000} seconds`);
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Save the script as src/revalidate.ts. The contact list is inline so the example is self-contained; replace it with a read from your CRM export:
import { balance, runRoutine, sleep } from "./clay";
const WORK_EMAIL = "function:t_0tjqmaaroo9mQFCBA5C";
const STALE_AFTER_DAYS = 180;
const today = new Date("2026-10-12");
interface Contact {
id: string;
fullName: string;
companyName: string;
companyDomain: string;
storedEmail: string;
lastVerified: string;
}
// A CRM export. Stored addresses here are first-name guesses, the kind of value a list carries after a pattern-based fill.
const contacts: Contact[] = [
{ id: "c1", fullName: "Patrick Collison", companyName: "Stripe", companyDomain: "stripe.com", storedEmail: "[email protected]", lastVerified: "2025-11-02" },
{ id: "c2", fullName: "Tobi Lutke", companyName: "Shopify", companyDomain: "shopify.com", storedEmail: "[email protected]", lastVerified: "2026-09-20" },
{ id: "c3", fullName: "Dylan Field", companyName: "Figma", companyDomain: "figma.com", storedEmail: "[email protected]", lastVerified: "2025-06-14" },
{ id: "c4", fullName: "Zzyzx Qwertyson", companyName: "Notion", companyDomain: "notion.so", storedEmail: "[email protected]", lastVerified: "2025-03-01" },
];
function daysSince(date: string): number {
return Math.floor((today.getTime() - new Date(date).getTime()) / 86_400_000);
}
function mask(email: string): string {
const [, domain] = email.split("@");
return `***@${domain}`;
}
async function main() {
const stale = contacts.filter((c) => daysSince(c.lastVerified) > STALE_AFTER_DAYS);
console.log(JSON.stringify({ total: contacts.length, stale: stale.length, skipped: contacts.filter((c) => !stale.includes(c)).map((c) => c.id) }));
const before = await balance();
const results = await runRoutine(
WORK_EMAIL,
stale.map((c) => ({
id: c.id,
inputs: { "Full Name": c.fullName, "Company Domain": c.companyDomain, "Company Name": c.companyName },
})),
);
await sleep(5000);
const after = await balance();
const byId = new Map((results.data ?? []).map((r) => [r.id, r]));
for (const c of stale) {
const item = byId.get(c.id);
const found = (item?.result?.["Work Email"] as string | undefined)?.toLowerCase();
const status = !found ? "not_found" : found === c.storedEmail.toLowerCase() ? "unchanged" : "changed";
console.log(JSON.stringify({
id: c.id,
status,
stored: mask(c.storedEmail),
found: found ? mask(found) : null,
sameDomain: found ? found.endsWith(`@${c.companyDomain}`) : null,
}));
}
console.log(JSON.stringify({ runStatus: results.status, rowsRun: stale.length, creditsSpent: before - after }));
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
5. Run
npx tsc node --env-file=.env dist/revalidate.js
6. Verify the result
Live output, recorded on 2026-10-12 in workspace 1322578:
{"total":4,"stale":3,"skipped":["c2"]}
{"id":"c1","status":"changed","stored":"***@stripe.com","found":"***@stripe.com","sameDomain":true}
{"id":"c3","status":"changed","stored":"***@figma.com","found":"***@figma.com","sameDomain":true}
{"id":"c4","status":"not_found","stored":"***@notion.so","found":null,"sameDomain":null}
{"runStatus":"complete","rowsRun":3,"creditsSpent":9}
elapsed_s=69
The check is a set of properties: stale equals the number of rows older than the cutoff, the fresh row is listed under skipped and is not sent, each stale row has exactly one status, and creditsSpent is at most the listed estimate times the rows sent when nothing else is spending credits in the workspace. The statuses above are what these three contacts returned that day, and another run can return a different address or none.
How it works
The age filter runs before any call, so a fresh row costs nothing. The remaining rows go out as one inline run. For each result, a present Work Email key is compared with the stored address, and an absent key becomes not_found.
The changed rows in this run are on the contact's own domain. The stored values were first-name guesses such as patrick@, which is the case this example is built for: a list filled by pattern, then checked against a provider result.
Common issues
A short polling window loses the results of a run that still bills
The first attempt at this example used a 2-minute polling limit and stopped with still in progress after 120 seconds. The run kept going after the script stopped waiting, and fetching it later by its id returned all three rows. A script that gives up early still pays for the run and loses the results. Poll for at least 10 minutes, and log the routine_run_id so an interrupted script can fetch the results afterward.
Run time varies
The same three rows took about 70 seconds in the verified run and over 3 minutes in the earlier one. Do not use the first run's timing as a timeout.
A missing result is not proof that the old address is bad
not_found means no provider returned an address for that name and domain. The stored address may still work. The routine output has no provider name or validation field, so treat the comparison as one signal and keep the stored value until another check disagrees.
A row with no address still costs credits
The 3 rows cost 9 credits in total, which matches the listed 3 credits per run for each row, including the one that returned nothing. The batch 6 examples measured one empty result that cost nothing and another that cost the full price, so do not rely on empty rows being free.
Lists longer than 20 rows
The results endpoint returns 20 rows per page, so the helper requests limit=100 and follows the cursor. The helper was checked on a 25-row run of another routine and returned all 25 rows, so no row beyond the first page is mistaken for a missing one.
The credit delta includes other jobs
The script measures spend as a difference of two balance reads. During an audit run in a shared workspace with other jobs running at the same time, the same script reported 47 credits for these 3 rows. The routine's own cost was 9 in both runs recorded above, which ran with nothing else active. Treat the printed figure as an upper bound when the workspace is busy.
Mask addresses before logging
The script prints ***@domain so a log file never holds a contact's address. Keep the full value in the CRM, not in the job output.
Next steps
- Finding a Verified Work Email with the Clay CLI
- Refreshing the Stalest Contacts in a CSV Within a Credit Budget with Python
verification: status: verified tested_at: "2026-10-12" product_version: "public/v0" command: "npx tsc && node --env-file=.env dist/revalidate.js" expected_result: "Stale rows are selected by age, the fresh row is skipped, each stale row gets one status, and creditsSpent is 9 for 3 rows."