Reading Every Row of a Run over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Reading Every Row of a Run over REST with TypeScript
GET /routines/run/{id}/results returns 20 rows per request unless a limit is passed, and it returns a cursor while more rows remain. A run of 25 rows read with the default request returned 20 rows, HTTP 200, and status: "complete", so the shortfall is visible only by comparing the row count with total.
What you will build
A script that submits 25 Company Domain rows, waits for the run to finish, then reads the results three ways and checks each one against the submitted ids: the default request, one with limit=100, and a loop with limit=10 that follows the cursor.
POST /routines/{id}/run -> routine_run_id
↓
GET /routines/run/{id}/results?limit=1 (poll: status, total, finished)
↓
GET /routines/run/{id}/results -> 20 rows + cursor (default)
GET /routines/run/{id}/results?limit=100 -> 25 rows, no cursor
GET /routines/run/{id}/results?limit=10&cursor=... -> 10, 10, 5 rows
↓
compare returned ids with submitted ids
AI Prompt
Implement a TypeScript script that reads every row of a finished Clay routine run.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Start a run with POST /routines/function:t_0tjqm9rUjfGY4MunGDM/run (Company Domain) and body { items: [{ id, inputs: { "Company Name": ... } }] } with 25 items. The response is { routine_run_id, status: "in_progress" }.
- GET /routines/run/{id}/results returns { routine_run_id, status, total, finished, data, cursor }. It answers 202 while the run is in progress and 200 once it is not. A read right after the start returned status in_progress, total 25, finished 0, and no rows.
- Without a limit the endpoint returns 20 rows. If more remain, the response has a "cursor". Pass it back as &cursor=<value>, URL-encoded. The last page has no cursor.
- limit must be between 1 and 100. 0 and -1 return 400 "Too small: expected number to be >=1", 101 returns 400 "Too big: expected number to be <=100", and a non-number returns 400. A cursor that was not issued by the API returns 400 "Invalid cursor".
- Rows are not returned in submission order. Match them by id.
- Poll with limit=1 so the poll request stays small. Stop after 6 minutes.
- Read the rows with limit=100 and with limit=10. Both loops follow the cursor until it is absent and stop if a cursor repeats. Also read once without any limit.
- For each reader report returned, duplicates, missing and unexpected ids against the submitted ids. The two cursor readers must report all 25 ids exactly once, and the default reader must report fewer, otherwise the check proves nothing.
- Exit with code 1 if the check fails. Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - About 25 credits, since
Company Domaincosts 1 credit per row
1. Create the project
mkdir clay-read-all && cd clay-read-all npm init -y npm install -D typescript @types/node mkdir src
Use this tsconfig.json. TypeScript 6 and later only loads @types packages that are listed, so types names node:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
2. Discover the real schema
Invalid limit and cursor values return 400. Run these against the id of any finished run:
BASE=https://api.clay.com/public/v0
for q in "limit=0" "limit=101" "limit=abc" "cursor=garbage"; do
curl -s -w ' [%{http_code}]\n' -H "clay-api-key: $CLAY_API_KEY" "$BASE/routines/run/$RUN_ID/results?$q"
done
Output from a finished run of 5 rows:
{"message":"Invalid request parameter(s): Field \"limit\" - Too small: expected number to be >=1"} [400]
{"message":"Invalid request parameter(s): Field \"limit\" - Too big: expected number to be <=100"} [400]
{"message":"Invalid request parameter(s): Field \"limit\" - Invalid input: expected number, received NaN"} [400]
{"message":"Invalid cursor"} [400]
3. Configure credentials
Create .env:
CLAY_API_KEY=
Keep .env out of version control.
4. Implement the script
src/clay.ts wraps fetch and declares the response types:
const BASE = "https://api.clay.com/public/v0";
export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const key = process.env.CLAY_API_KEY;
if (!key) throw new Error("CLAY_API_KEY is not set");
const res = await fetch(`${BASE}${path}`, {
method: init.method ?? "GET",
headers: { "clay-api-key": key, "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 RunRow {
id: string;
status: string;
result?: Record<string, unknown>;
error?: { message: string };
}
export interface RunPage {
routine_run_id: string;
status: string;
total: number;
finished: number;
data?: RunRow[];
cursor?: string;
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
src/read-all-rows.ts holds the three readers and the comparison:
import { clay, sleep, RunPage, RunRow } from "./clay";
const COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM";
const names = [
"Notion", "Figma", "Linear", "Vercel", "Supabase", "Stripe", "Airtable", "Asana", "Slack", "Zoom",
"Datadog", "Snowflake", "Okta", "Twilio", "Shopify", "HubSpot", "Canva", "Miro", "Loom", "Retool",
"Webflow", "Zapier", "Gusto", "Brex", "Ramp",
];
// Reads one page only. This is what a caller gets by not passing limit or following the cursor.
async function firstPageOnly(runId: string): Promise<RunPage> {
return clay<RunPage>(`/routines/run/${runId}/results`);
}
// Follows the cursor until the last page, which has no cursor.
async function readEveryRow(runId: string, pageSize: number): Promise<{ rows: RunRow[]; pages: number }> {
const rows: RunRow[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
let pages = 0;
do {
const qs = `limit=${pageSize}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
const page = await clay<RunPage>(`/routines/run/${runId}/results?${qs}`);
rows.push(...(page.data ?? []));
pages++;
cursor = page.cursor;
if (cursor) {
if (seenCursors.has(cursor)) throw new Error("cursor repeated; stopping to avoid an endless loop");
seenCursors.add(cursor);
}
} while (cursor);
return { rows, pages };
}
async function main() {
const items = names.map((name, i) => ({ id: `c${String(i + 1).padStart(2, "0")}`, inputs: { "Company Name": name } }));
const started = await clay<{ routine_run_id: string }>(`/routines/${COMPANY_DOMAIN}/run`, { method: "POST", body: { items } });
const runId = started.routine_run_id;
// A read taken straight after the start shows what a partial run looks like.
const early = await firstPageOnly(runId);
console.log(JSON.stringify({ step: "early read", status: early.status, total: early.total, finished: early.finished, returned: early.data?.length ?? 0 }));
// Poll with limit=1 so the poll itself stays small.
let head = await clay<RunPage>(`/routines/run/${runId}/results?limit=1`);
for (let i = 0; head.status === "in_progress" && i < 120; i++) {
await sleep(3000);
head = await clay<RunPage>(`/routines/run/${runId}/results?limit=1`);
}
if (head.status === "in_progress") throw new Error("run still in progress after 6 minutes");
const naive = await firstPageOnly(runId);
const single = await readEveryRow(runId, 100);
const paged = await readEveryRow(runId, 10);
const submitted = new Set(items.map((i) => i.id));
const check = (label: string, rows: RunRow[]) => {
const ids = rows.map((r) => r.id);
const unique = new Set(ids);
return {
reader: label,
returned: rows.length,
duplicates: ids.length - unique.size,
missing: [...submitted].filter((id) => !unique.has(id)).length,
unexpected: [...unique].filter((id) => !submitted.has(id)).length,
};
};
const naiveReport = { ...check("default page, no cursor", naive.data ?? []), hasCursor: Boolean(naive.cursor) };
const reports = [
naiveReport,
{ ...check("limit=100, follow cursor", single.rows), pages: single.pages },
{ ...check("limit=10, follow cursor", paged.rows), pages: paged.pages },
];
reports.forEach((r) => console.log(JSON.stringify(r)));
console.log(JSON.stringify({ status: head.status, total: head.total, finished: head.finished, submitted: items.length }));
// The two cursor readers must return every submitted row exactly once. The default read must be
// visibly short here, otherwise this fixture does not contain the case the example is about.
const complete = (r: { returned: number; duplicates: number; missing: number; unexpected: number }) =>
r.returned === items.length && r.duplicates === 0 && r.missing === 0 && r.unexpected === 0;
const ok = complete(reports[1]) && complete(reports[2]) && !complete(naiveReport) && naiveReport.hasCursor;
const sample = [...single.rows].sort((a, b) => a.id.localeCompare(b.id)).slice(0, 3);
console.log(JSON.stringify({ sample: sample.map((r) => ({ id: r.id, status: r.status, domainIsString: typeof r.result?.["Domain"] === "string" })) }));
if (!ok) {
console.error("verification failed");
process.exit(1);
}
console.log("verification passed");
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
5. Run
npx tsc node --env-file=.env dist/read-all-rows.js; echo "exit=$?"
6. Verify the result
Output of the script on 2026-10-20, workspace 1322578:
{"step":"early read","status":"in_progress","total":25,"finished":0,"returned":0}
{"reader":"default page, no cursor","returned":20,"duplicates":0,"missing":5,"unexpected":0,"hasCursor":true}
{"reader":"limit=100, follow cursor","returned":25,"duplicates":0,"missing":0,"unexpected":0,"pages":1}
{"reader":"limit=10, follow cursor","returned":25,"duplicates":0,"missing":0,"unexpected":0,"pages":3}
{"status":"complete","total":25,"finished":25,"submitted":25}
{"sample":[{"id":"c01","status":"complete","domainIsString":true},{"id":"c02","status":"complete","domainIsString":true},{"id":"c03","status":"complete","domainIsString":true}]}
verification passed
exit=0
The checks are properties of the reads, not of the enrichment output. The fixture holds 25 rows so that it crosses the 20-row default. Both cursor readers return all 25 submitted ids once each, whatever page size they use. The default reader returns 20 ids and reports 5 missing even though the run status is complete and the response carries a cursor; that shortfall is the negative control, and the script exits 1 if the default reader ever returns all 25. The domain values are not checked, because enrichment output can differ between runs.
How it works
The endpoint pages its results. A request without limit gets a page of 20. When rows remain, the response includes a cursor, and sending that value back with the same limit returns the next page. The last page omits the cursor, which ends the loop. The total and finished fields describe the whole run and do not change with the page size, so data.length summed across pages is the number to compare with total.
The script polls with limit=1 because only status, total and finished are needed while waiting. It reads the rows after the status leaves in_progress. Reading with limit=100 fetches 100 rows per request, which is the largest page the API accepts, so the 25-row run here needs one request.
Common issues
A read of the default page can look like a complete result
The response for the first 20 rows has the same status code (200), the same status value and the same total as a complete read. Code that maps over data and stops there handles 20 rows of a 25-row run without any error. Compare the number of rows collected with total, or follow the cursor until it is absent.
The cursor is opaque
A cursor is a short string that the API issues with a page. Pass it back unchanged and URL-encode it. A value the API did not issue returns 400 with Invalid cursor. This example did not test how long an issued cursor stays valid.
limit above 100 is rejected rather than reduced
limit=101 returns 400, so the request fails instead of returning 100 rows. A script that builds limit from the number of rows it expects needs to cap it at 100.
A read before the run finishes returns no rows
A request sent immediately after the start returned in_progress, total: 25, finished: 0 and no rows, with HTTP 202. This example did not read rows from a run that was part-way through, so it makes no claim about what a partial page contains. Wait for a status other than in_progress first.
Rows are not in submission order
Rows were not returned in the order they were submitted. A 3-row run submitted as ok, gib, extra came back as gib, extra, ok. Use the id you supplied to match results to inputs.
The inline run endpoint accepted 25 rows
Larger inline requests were not tried, so the maximum size of one POST /routines/{id}/run request is not known from this example. For large inputs the upload-url, start and results flow in Bulk Enriching a CSV of Companies over REST with Python does not depend on the inline size.
Next steps
- Following a CLI-Started Run over REST with TypeScript
- Retrying Failed Rows of a Batch over REST with TypeScript
verification: status: verified tested_at: "2026-10-20" product_version: "public/v0" command: "npx tsc && node --env-file=.env dist/read-all-rows.js" expected_result: "The limit=100 and limit=10 cursor readers each return all 25 submitted ids once with no duplicates, the default reader returns 20 and reports 5 missing, and the script prints `verification passed` and exits 0."