clay.com

Command Palette

Search for a command to run...

Bulk Enriching a CSV Upload with TypeScript

Last updated: 9/9/2026

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 via clay routines list
  • npm install multer csv-parse for 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 Company for 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."