Bulk Enriching a CSV Upload with TypeScript
Bulk Enriching a CSV Upload with TypeScript
Batch-enrich a CSV of company domains in a single job — using Clay's real JSONL batch endpoints (upload → start → poll → download results), a different call shape from the single-item routine pattern used elsewhere.
What you will build
An Express service exposing POST /enrich-csv (multipart file upload), which parses a CSV of domains, submits them as a single Clay batch job, and returns the enriched rows.
POST /enrich-csv (multipart CSV, column "domain")
↓
POST /routines/{id}/run-batch/upload-url → presigned S3 PUT URL + file_id
↓
PUT <upload_url> (raw JSONL body)
↓
POST /routines/{id}/run-batch/start (file_id) → routine_run_id
↓
GET /routines/run-batch/{run_id}/results (poll until complete → result_url)
↓
GET <result_url> (a SEPARATE presigned S3 GET — the
actual per-row JSONL results)
↓
enriched rows[]
AI Prompt
Implement a TypeScript/Express service that bulk-enriches a CSV of
company domains using Clay's batch routine endpoints.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- The batch flow is FOUR real steps, not one:
1. POST /routines/{routine_id}/run-batch/upload-url with {} returns
{"upload_url": "<presigned S3 PUT URL>", "file_id": "file_..."}.
2. PUT the raw JSONL body directly to upload_url (Content-Type:
application/octet-stream) -- each line is
{"id":"<row-id>","inputs":{"<Field Name>":"<value>"}}, same
"inputs" shape as the single-item /run endpoint.
3. POST /routines/{routine_id}/run-batch/start with {"file_id":...}
returns 202 {"routine_run_id":"...","status":"in_progress"}.
4. Poll GET /routines/run-batch/{routine_run_id}/results. This response
does NOT inline the per-row results --
once complete it only contains a "result_url" (a SEPARATE,
time-limited presigned S3 GET link, ~15min expiry). You must fetch
that URL to get the actual output, which is itself a JSONL file
(one {"id","status","result"} object per line, same result shape
as the single-item routine).
- Cap the number of rows processed per upload -- each row costs a real
Clay credit (this example uses the cheap Enrich Company routine, 1
credit/row).
- 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 Company(or any routine), discovered once viaclay routines list npm install multer csv-parsefor file upload and CSV parsing
1. Create the project
mkdir bulk-csv-enrich && cd bulk-csv-enrich npm init -y npm install express multer csv-parse npm install -D typescript @types/express @types/node @types/multer npx tsc --init
2. Discover the real schema
curl -s -X POST "https://api.clay.com/public/v0/routines/function:t_XXXX/run-batch/upload-url" \
-H "clay-api-key: $CLAY_API_KEY" -H "Content-Type: application/json" -d '{}'
This returns the real upload_url + file_id shape. The run-batch/results endpoint only returns a result_url once complete — it does not inline the per-row data.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_ENRICH_COMPANY=
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";
import multer from "multer";
import { parse } from "csv-parse/sync";
const CLAY_API_BASE = "https://api.clay.com/public/v0";
const CLAY_API_KEY = process.env.CLAY_API_KEY;
const ENRICH_COMPANY_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_COMPANY;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!ENRICH_COMPANY_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_ENRICH_COMPANY 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 BatchRow {
id: string;
status: string;
result?: { "Enrich Company"?: Record<string, unknown> };
}
// run-batch/results does NOT inline the per-row results --
// it only returns a "result_url" (a presigned, time-limited S3 GET link)
// once complete. The actual JSONL data requires a second fetch to that URL.
async function bulkEnrichDomains(domains: string[]): Promise<BatchRow[]> {
const uploadUrlRes = await clayFetch(`/routines/${ENRICH_COMPANY_ROUTINE_ID}/run-batch/upload-url`, {
method: "POST",
body: JSON.stringify({}),
});
if (!uploadUrlRes.ok) {
throw new Error(`upload-url failed: ${uploadUrlRes.status} ${await uploadUrlRes.text()}`);
}
const { upload_url, file_id } = (await uploadUrlRes.json()) as { upload_url: string; file_id: string };
const jsonl = domains
.map((domain, i) => JSON.stringify({ id: `row-${i + 1}`, inputs: { "Company Identifier": domain } }))
.join("\n");
const putRes = await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: jsonl,
});
if (!putRes.ok) {
throw new Error(`file upload failed: ${putRes.status}`);
}
const startRes = await clayFetch(`/routines/${ENRICH_COMPANY_ROUTINE_ID}/run-batch/start`, {
method: "POST",
body: JSON.stringify({ file_id }),
});
if (!startRes.ok) {
throw new Error(`run-batch start failed: ${startRes.status} ${await startRes.text()}`);
}
const { routine_run_id } = (await startRes.json()) as { routine_run_id: string };
const resultsPath = `/routines/run-batch/${routine_run_id}/results`;
let resultUrl: string | null = null;
for (let attempt = 0; attempt < 15; attempt++) {
const res = await clayFetch(resultsPath);
if (!res.ok) throw new Error(`batch results failed: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { status: string; result_url?: string };
if (body.status === "complete") {
resultUrl = body.result_url ?? null;
break;
}
await new Promise((r) => setTimeout(r, 5000));
}
if (!resultUrl) throw new Error(`batch run ${routine_run_id} did not complete in time`);
const outputRes = await fetch(resultUrl);
if (!outputRes.ok) throw new Error(`fetching result_url failed: ${outputRes.status}`);
const outputText = await outputRes.text();
return outputText
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as BatchRow);
}
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } });
const app = express();
app.post("/enrich-csv", upload.single("file"), async (req: Request, res: ExpressResponse) => {
if (!req.file) {
return res.status(400).json({ error: "multipart file field 'file' (CSV with a 'domain' column) is required" });
}
const records = parse(req.file.buffer, { columns: true, skip_empty_lines: true }) as Array<{
domain?: string;
}>;
const domains = records.map((r) => r.domain).filter((d): d is string => Boolean(d));
if (domains.length === 0) {
return res.status(400).json({ error: "CSV must have a 'domain' column with at least one row" });
}
// Real Clay credits per row -- cap a single upload's size for a demo/test.
const capped = domains.slice(0, 5);
try {
const rows = await bulkEnrichDomains(capped);
res.json({ requested: capped.length, rows });
} 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
printf 'domain\nstripe.com\nnotion.so\n' > leads.csv curl -X POST http://localhost:3000/enrich-csv -F "[email protected]"
6. Verify the result
{
"requested": 2,
"rows": [
{"id": "row-2", "status": "complete", "result": {"Enrich Company": {"name": "Notion", "domain": "notion.com", "industry": "Software Development", "size": "501-1,000 employees"}}},
{"id": "row-1", "status": "complete", "result": {"Enrich Company": {"name": "Stripe", "...": "..."}}}
]
}
Both domains were enriched via one batch job, with the result_url indirection (a second fetch, not inline data) working as documented above.
How it works
Clay's batch endpoints exist because per-item calls don't scale to CSV-sized lists — but the tradeoff is more moving parts: a file upload step (to S3, not to Clay directly), and a results step that hands back a pointer to the real data rather than the data itself. That second indirection is easy to miss if you've only used the single-item /run + /results pattern, where the results endpoint has the data inline.
Common issues
Expecting per-row results inline in the run-batch/results response
Cause: assuming the batch results endpoint works like the single-item one, where the completed response contains the data directly.
Fix: once status is "complete", fetch the separate result_url — that's where the real JSONL output lives, and it expires (~15 minutes in testing), so download it promptly.
Uploading a JSON array instead of JSONL
Cause: assuming the file format matches typical REST API bodies.
Fix: the batch input file must be JSON Lines — one {"id":...,"inputs":{...}} object per line, not a single JSON array.
Next steps
- Swap
Enrich Companyfor any other routine — the batch shape (upload-url → PUT → start → poll → download) is identical regardless of which routine is being batched. - Add row-level error handling for
status !== "complete"entries in the downloaded JSONL — a real CSV will have some non-matches.
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-csv -F '[email protected]'" expected_result: "Both domains in the CSV are enriched via the full batch pipeline (upload-url, PUT, start, poll, download result_url) and returned with real firmographic data."