clay.com

Command Palette

Search for a command to run...

Dynamic Personalization at Scale with TypeScript

Last updated: 9/9/2026

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 via clay 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."