Scheduling a Recurring Enrichment over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Scheduling a Recurring Enrichment over REST with TypeScript
A Node process can run a Clay routine on a cron schedule with the node-cron package, with no workflow UI and no external scheduler. This example ran an enrichment every minute for three executions, and noOverlap skipped each tick that arrived while the previous run was still active.
What you will build
A long-running script that registers a cron schedule, runs a two-row Company Domain enrichment on each tick, logs one JSON line per event, and exits after a set number of executions.
node-cron "* * * * *" (noOverlap: true, maxExecutions: 3)
↓ tick
POST /routines/{Company Domain}/run -> poll /routines/run/{id}/results
↓
log finished { rows, failed, creditsSpent }
↓ tick while still running
log skipped { reason: "previous run still active" }
AI Prompt
Implement a TypeScript script that runs a Clay routine on a recurring schedule using node-cron.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use node-cron 4.x. Create the task with cron.schedule(expression, handler, { name, noOverlap: true, maxExecutions }). Validate the expression first with cron.validate and exit with code 2 if it is invalid.
- With noOverlap, a tick that arrives while the previous execution is still running does not start a second one. The task emits "execution:overlap". Log it as a skipped event.
- The task emits "execution:maxReached" when maxExecutions is reached. Wait until task.isBusy() is false, then call task.destroy() and exit 0.
- Each execution runs the "Company Domain" routine, function:t_0tjqm9rUjfGY4MunGDM, over two companies. Its input is "Company Name" and its result is { "Domain": "<domain>" }. It costs 1 credit per row.
- Run the routine 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". Poll for up to 10 minutes.
- Read GET /credits/balance before the run and a few seconds after it completes, and log the credits spent.
- For the test, an environment variable EXTRA_DELAY_MS adds a delay after the run, to stand in for a slow step.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+, TypeScript, and the
node-cronpackage - A Clay Public API key (
clay_scoped_...) - Six or more credits, because three executions each enrich two companies
1. Create the project
mkdir clay-scheduled && cd clay-scheduled npm init -y npm install node-cron npm install -D typescript @types/node mkdir src
Save this as tsconfig.json. TypeScript 6 and later requires the types entry, because it no longer loads @types packages by itself:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
The runs below used node-cron 4.6.0.
2. Configure credentials
Create .env:
CLAY_API_KEY=
Keep .env out of version control.
3. Implement the script
Put the helper in src/clay.ts:
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/scheduled.ts:
import cron from "node-cron";
import { balance, runRoutine, sleep } from "./clay";
const COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM";
const SCHEDULE = process.env.SCHEDULE ?? "* * * * *";
const MAX_RUNS = Number(process.env.MAX_RUNS ?? "3");
const EXTRA_DELAY_MS = Number(process.env.EXTRA_DELAY_MS ?? "0");
const accounts = ["Notion", "Figma"];
const stamp = () => new Date().toISOString().slice(11, 19);
const log = (event: string, extra: Record<string, unknown> = {}) => console.log(JSON.stringify({ at: stamp(), event, ...extra }));
async function enrichAccounts(): Promise<void> {
const before = await balance();
const results = await runRoutine(
COMPANY_DOMAIN,
accounts.map((name, i) => ({ id: String(i), inputs: { "Company Name": name } })),
);
// Stand-in for a slow step, so the next tick can arrive while this run is still active.
if (EXTRA_DELAY_MS > 0) await sleep(EXTRA_DELAY_MS);
const failed = (results.data ?? []).filter((r) => r.status !== "complete").map((r) => r.id);
await sleep(5000);
log("finished", { status: results.status, rows: accounts.length, failed, creditsSpent: before - (await balance()) });
}
if (!cron.validate(SCHEDULE)) {
console.error(`Invalid cron expression: ${SCHEDULE}`);
process.exit(2);
}
const task = cron.schedule(
SCHEDULE,
async () => {
log("started");
await enrichAccounts();
},
{ name: "enrich-accounts", noOverlap: true, maxExecutions: MAX_RUNS },
);
task.on("execution:overlap", () => log("skipped", { reason: "previous run still active" }));
task.on("execution:failed", (ctx) => log("failed", { error: String(ctx.error) }));
task.on("execution:maxReached", async () => {
log("max_runs_reached", { maxRuns: MAX_RUNS });
while (task.isBusy()) await sleep(1000);
await task.destroy();
process.exit(0);
});
log("scheduled", { schedule: SCHEDULE, nextRun: task.getNextRun()?.toISOString() });
4. Run
npx tsc MAX_RUNS=3 EXTRA_DELAY_MS=70000 node --env-file=.env dist/scheduled.js
5. Verify the result
Log from 2026-10-12. The 70-second delay makes each execution last about 90 seconds, longer than the one-minute interval:
{"at":"00:37:57","event":"scheduled","schedule":"* * * * *","nextRun":"2026-10-13T00:38:00.000Z"}
{"at":"00:38:00","event":"started"}
{"at":"00:39:00","event":"skipped","reason":"previous run still active"}
[2026-10-13T00:39:00.012Z] [PID: 99824] [NODE-CRON] [WARN] task still running, new execution blocked by overlap prevention!
{"at":"00:39:31","event":"finished","status":"complete","rows":2,"failed":[],"creditsSpent":2}
{"at":"00:40:00","event":"started"}
{"at":"00:41:00","event":"skipped","reason":"previous run still active"}
[2026-10-13T00:41:00.005Z] [PID: 99824] [NODE-CRON] [WARN] task still running, new execution blocked by overlap prevention!
{"at":"00:41:31","event":"finished","status":"complete","rows":2,"failed":[],"creditsSpent":2}
{"at":"00:42:00","event":"started"}
{"at":"00:43:00","event":"skipped","reason":"previous run still active"}
[2026-10-13T00:43:00.030Z] [PID: 99824] [NODE-CRON] [WARN] task still running, new execution blocked by overlap prevention!
{"at":"00:43:32","event":"finished","status":"complete","rows":2,"failed":[],"creditsSpent":2}
{"at":"00:43:32","event":"max_runs_reached","maxRuns":3}
exit=0
The check is a set of properties: executions start on the minute, the ticks that fall inside a running execution are logged as skipped and do not start a second run, each finished line reports rows of 2 and an empty failed list, and the process exits 0 after the third started execution finishes.
How it works
node-cron computes the next tick from the expression and calls the handler. With noOverlap, a tick that arrives while the handler's promise is still pending is dropped and an execution:overlap event fires, so two copies of the enrichment never run at once. Skipped ticks did not count toward maxExecutions: three executions started and finished, and three ticks were skipped.
In production the schedule is a real interval such as 0 6 * * *, EXTRA_DELAY_MS is unset, and maxExecutions is removed so the process keeps running.
Common issues
The schedule lives in the process
If the process stops, nothing runs. node-cron is an in-process scheduler. Run it under a process manager such as systemd or a container restart policy, or start a one-shot version of the script from the system's cron instead.
Overlap prevention applies within one process
noOverlap tracks executions in the process that holds the task. Two copies of the script on two machines each run their own schedule. The library's type definitions include a distributed option and a run-coordinator hook for that case, which this example does not use.
The overlap warning is printed by the library
node-cron writes its own WARN line, task still running, new execution blocked by overlap prevention!, in addition to the skipped line this script logs. Both lines appear in the output above.
Larger account lists
The two-company list fits in one page. For a list over 20 accounts the helper requests limit=100 and follows the cursor, because the endpoint returns 20 rows per page by default.
Invalid expressions
cron.validate returns false for an expression it cannot parse. With SCHEDULE="not a cron" the script printed Invalid cron expression: not a cron and exited with code 2 before scheduling anything.
Next steps
- Running a Clay Enrichment from Cron with the Clay CLI
- Running a Nightly Clay Enrichment from Cron with Python
verification: status: verified tested_at: "2026-10-12" product_version: "public/v0" command: "npx tsc && MAX_RUNS=3 EXTRA_DELAY_MS=70000 node --env-file=.env dist/scheduled.js" expected_result: "Three executions start and finish, overlapping ticks are logged as skipped, each finished line has rows of 2 and no failed rows, and the process exits 0."