Dynamic Personalization at Scale with TypeScript
Dynamic Personalization at Scale with TypeScript
Generate a real, per-company personalization token across many companies in one batch job — combining Clay's real batch endpoints with the Company News routine, rather than a separate "personalization routine" (no such thing exists).
What you will build
An Express service exposing POST /personalize-at-scale, which batch-fetches real recent news for a list of company domains and returns one personalization token per domain.
POST /personalize-at-scale (domains[])
↓
POST /routines/{id}/run-batch/upload-url → presigned S3 PUT URL + file_id
↓
PUT <upload_url> (JSONL: one Company News input per domain)
↓
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> (separate JSONL fetch — the real output)
↓
per-domain personalization token (from real news summary)
AI Prompt
Implement a TypeScript/Express service that generates personalization
tokens at scale for a list of company domains using Clay's real batch
routine endpoints.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- There is no dedicated "personalization routine" -- this pattern
batches the ordinary "Company News" routine (id via
CLAY_ROUTINE_ID_COMPANY_NEWS) across many domains, then extracts a
token from each row's real result.
- The batch flow is the same 4 real steps used for bulk CSV
enrichment: (1) POST /routines/{id}/run-batch/upload-url returns
{"upload_url","file_id"}; (2) PUT JSONL (one
{"id":"<domain>","inputs":{"Company Domain":"<domain>","Max News
Events":"1"}} per line) to upload_url; (3) POST
/routines/{id}/run-batch/start with {"file_id"} returns
{"routine_run_id","status":"in_progress"}; (4) poll GET
/routines/run-batch/{routine_run_id}/results. The
completed response does NOT inline results, only a separate
"result_url" (presigned S3 GET, time-limited) that must be fetched
to get the real per-row JSONL.
- Each row's usable content is under "Find Most Recent News" ->
events[0].summary -- NOT the top-level "Company News" string field.
- Cap batch size at <=3 domains per request -- each row costs a real
Clay credit (~6.7 credits/row for Company News).
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - The routine ID for
Company News, discovered once viaclay routines list
1. Create the project
mkdir personalization-at-scale && cd personalization-at-scale npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
Reuses the batch mechanics from the bulk-CSV-enrichment example and the Company News result shape from the personalized-copy example.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_COMPANY_NEWS=
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";
const CLAY_API_BASE = "https://api.clay.com/public/v0";
const CLAY_API_KEY = process.env.CLAY_API_KEY;
const COMPANY_NEWS_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_COMPANY_NEWS;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!COMPANY_NEWS_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_COMPANY_NEWS 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 NewsEvent {
summary?: string;
}
interface BatchRow {
id: string;
status: string;
result?: { "Find Most Recent News"?: { events?: NewsEvent[] } };
}
// The batch mechanics here are identical to the bulk-csv-enrichment example
// -- upload-url, PUT JSONL, start, poll, then a SEPARATE fetch to result_url
// (the run-batch/results response never inlines the data).
// What's different is the routine (Company News) and the post-processing:
// each row's real news summary becomes a personalization token.
async function batchFetchNewsTokens(domains: string[]): Promise<Array<{ domain: string; token: string | null }>> {
const uploadUrlRes = await clayFetch(`/routines/${COMPANY_NEWS_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) =>
JSON.stringify({ id: domain, inputs: { "Company Domain": domain, "Max News Events": "1" } }),
)
.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/${COMPANY_NEWS_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();
const rows = outputText
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as BatchRow);
return rows.map((row) => ({
domain: row.id,
token: row.result?.["Find Most Recent News"]?.events?.[0]?.summary ?? null,
}));
}
const app = express();
app.use(express.json());
app.post("/personalize-at-scale", async (req: Request, res: ExpressResponse) => {
const { domains } = req.body ?? {};
if (!Array.isArray(domains) || domains.length === 0) {
return res.status(400).json({ error: "domains must be a non-empty array" });
}
// Real Clay credits per row (Company News is ~6.7 credits/row) -- cap a
// single batch's size for a demo/test.
const capped = domains.slice(0, 3);
try {
const tokens = await batchFetchNewsTokens(capped);
res.json({ requested: capped.length, tokens });
} 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
curl -X POST http://localhost:3000/personalize-at-scale \
-H "Content-Type: application/json" \
-d '{"domains":["stripe.com","notion.so"]}'
6. Verify the result
{
"requested": 2,
"tokens": [
{"domain": "notion.so", "token": "Notion launched set of 3D icons on Aug 1st '26."},
{"domain": "stripe.com", "token": "Fireblocks Inc. identified as competitor of Stripe, Inc. on Aug 28th '26."}
]
}
Two distinct, real, dated personalization tokens were generated in one batch job, via the separate result_url fetch described above.
How it works
"At scale" here means one batch job replaces N individual /run calls — the mechanics are identical to any other batched routine (this session's bulk-CSV example uses the exact same four steps with a different routine). The "personalization" part is just what you do with each row's real result after downloading it: extract a fact and turn it into a token, same as the single-item personalized-copy example, just applied across a whole JSONL file's worth of rows in one job instead of one call per company.
Common issues
Looking for a batch-native "personalization" routine
Cause: the content plan's phrasing ("personalization routine at scale") suggests a dedicated capability.
Fix: it's the ordinary Company News routine (or any routine) run through the generic batch endpoints — there's nothing personalization-specific about the API surface itself.
Reading the top-level "Company News" field per row
Cause: assuming the batch output's per-row shape differs from the single-item shape.
Fix: it's identical — the real content is still under "Find Most Recent News" in both the single-item and batch examples.
Next steps
- Swap in a different routine (e.g.
Website Technology Stack) for a different kind of token, using the same batch mechanics unchanged. - Feed the resulting tokens into your outbound sequencing tool's merge-field system.
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/personalize-at-scale -d '{\"domains\":[\"stripe.com\",\"notion.so\"]}'"
expected_result: "Two real, distinct personalization tokens are generated for two real companies in a single batch job, via the separate result_url fetch."