Making a Budget-Capped Batch Call Over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Making a Budget-Capped Batch Call Over REST with TypeScript
The script reads the credit balance, divides a budget by a per-row cost, and sends only the rows that fit. With a budget of 3 credits and 5 rows, it ran 3 rows, skipped 2, and spent 3 credits.
What you will build
A script that caps a routine batch to a credit budget, reports which rows it skipped, and exits non-zero when nothing fits.
PER_ROW_CREDITS, BUDGET_CREDITS (settings)
↓
GET /credits/balance
↓
rows to run = floor(min(budget, balance) / perRow)
↓
POST /routines/{routineId}/run (only those rows)
↓
GET /routines/run/{runId}/results -> GET /credits/balance -> creditsSpent
AI Prompt
Implement a TypeScript script that runs a routine batch only as far as a credit budget allows.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- The REST API does not expose a routine's price: `GET /routines/{id}` returns 404. Take the per-row estimate from `clay routines get <id>` (`estimatedCreditCost.perRun`) and pass it in as `PER_ROW_CREDITS`. For `Company Domain` it is 1.
- `GET /credits/balance` returns `{ "balance": <number> }`.
- Rows to run = floor(min(BUDGET_CREDITS, balance) / PER_ROW_CREDITS), taken from the front of the list. Print the skipped rows.
- If no row fits, start no run and exit with code 1. If either setting is missing or invalid, exit with code 1 before any request.
- Run the verification step below before finishing.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - The per-row credit estimate of the routine, read with
clay routines get
1. Create the project
mkdir clay-example && cd clay-example npm init -y npm install -D typescript @types/node mkdir src
Create tsconfig.json. The types entry is required with TypeScript 6 and later, which no longer loads @types packages automatically, and the template from npx tsc --init turns on stricter module and optional-property options than this example's code is written for:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
Node.js 18 or later provides fetch, so no HTTP library is installed. The runs below used Node.js 24.
2. Discover the real schema
Get the per-row estimate with the CLI, which is where the price is exposed:
clay routines get function:t_0tjqm9rUjfGY4MunGDM | jq .estimatedCreditCost.perRun
curl -s -o /dev/null -w '%{http_code}\n' -H "clay-api-key: $CLAY_API_KEY" "https://api.clay.com/public/v0/routines/function:t_0tjqm9rUjfGY4MunGDM"
Real output:
1 404
3. Configure credentials
Create .env with a key from Creating an API Key and Making a First REST Call:
CLAY_API_KEY=
Keep .env out of version control.
4. Implement the script
Save this shared helper as src/clay.ts. It wraps fetch, reads the balance, and runs a routine to completion:
const BASE = "https://api.clay.com/public/v0";
function apiKey(): string {
const key = process.env.CLAY_API_KEY;
if (!key) throw new Error("CLAY_API_KEY is not set");
return key;
}
export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: init.method ?? "GET",
headers: { "clay-api-key": apiKey(), "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 RunItem {
id: string;
status: string;
result?: Record<string, unknown>;
}
export interface RunResults {
routine_run_id: string;
status: string;
total: number;
finished: number;
data?: RunItem[];
}
export async function balance(): Promise<number> {
return (await clay<{ balance: number }>("/credits/balance")).balance;
}
export async function runRoutine(routineId: string, rows: { id: string; inputs: Record<string, unknown> }[]): Promise<RunResults> {
const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, {
method: "POST",
body: { items: rows },
});
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 4000));
const results = await clay<RunResults>(`/routines/run/${started.routine_run_id}/results`);
if (results.status !== "in_progress") return results;
}
throw new Error(`run ${started.routine_run_id} still in progress after 120 seconds`);
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Save the script as src/budget-capped-batch.ts:
import { balance, runRoutine, sleep } from "./clay";
const COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM";
const companies = ["Notion", "Figma", "Linear", "Vercel", "Supabase"];
async function main() {
const perRow = Number(process.env.PER_ROW_CREDITS);
const budget = Number(process.env.BUDGET_CREDITS);
if (!(perRow > 0) || !(budget >= 0)) throw new Error("Set PER_ROW_CREDITS (> 0) and BUDGET_CREDITS (>= 0)");
const before = await balance();
const affordable = Math.floor(Math.min(budget, before) / perRow);
const toRun = companies.slice(0, affordable);
const skipped = companies.slice(affordable);
console.log(JSON.stringify({ balance: before, budget, perRow, rows: companies.length, running: toRun.length, skipped }));
if (toRun.length === 0) {
console.error("Nothing affordable within the budget; no run started");
process.exit(1);
}
const results = await runRoutine(
COMPANY_DOMAIN,
toRun.map((name, i) => ({ id: String(i), inputs: { "Company Name": name } })),
);
await sleep(5000);
const after = await balance();
const byId = new Map((results.data ?? []).map((r) => [r.id, r.result]));
toRun.forEach((name, i) => console.log(JSON.stringify({ name, result: byId.get(String(i)) })));
console.log(JSON.stringify({ status: results.status, creditsSpent: before - after }));
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
5. Run
npx tsc PER_ROW_CREDITS=1 BUDGET_CREDITS=3 node --env-file=.env dist/budget-capped-batch.js PER_ROW_CREDITS=1 BUDGET_CREDITS=0 node --env-file=.env dist/budget-capped-batch.js; echo "exit=$?" node --env-file=.env dist/budget-capped-batch.js; echo "exit=$?"
Real output on 2026-10-09:
{"balance":393062,"budget":3,"perRow":1,"rows":5,"running":3,"skipped":["Vercel","Supabase"]}
{"name":"Notion","result":{"Domain":"notion.com"}}
{"name":"Figma","result":{"Domain":"figma.com"}}
{"name":"Linear","result":{"Domain":"linear.app"}}
{"status":"complete","creditsSpent":3}
With BUDGET_CREDITS=0, which does not fit any row:
{"balance":393059,"budget":0,"perRow":1,"rows":5,"running":0,"skipped":["Notion","Figma","Linear","Vercel","Supabase"]}
Nothing affordable within the budget; no run started
exit=1
With neither setting present:
Set PER_ROW_CREDITS (> 0) and BUDGET_CREDITS (>= 0) exit=1
6. Verify the result
PER_ROW_CREDITS=1 BUDGET_CREDITS=3 node --env-file=.env dist/budget-capped-batch.js | tail -1
Expected: {"status":"complete","creditsSpent":3} with 3 of the 5 rows run.
How it works
The script computes how many rows the budget can pay for, truncates the list to that many, and sends one run. The balance is read before and after, so the printed creditsSpent is measured, not computed. The cap is applied before any request that spends credits.
Common issues
The cap depends on the estimate being right
A per-row estimate that is too low lets the batch spend more than the budget. The estimate matched the real charge for Company Domain and Work Email in this batch and was not checked for other routines. Confirm with a small run first, as in Measuring the Real Cost of a Routine Run.
The price has to come from the CLI
GET /routines/{id} returned 404 over REST on 2026-10-09, so the script takes the per-row figure as a setting. Read it with clay routines get.
Routines that return nothing can cost less
The script reserves the full perRow for every row. A routine that returns an empty result for a row can leave part of the budget unspent, which creditsSpent reports.
A budget check does not reserve credits
Another job in the workspace can spend credits between the balance read and the run.
Next steps
- Estimating a Batch Cost Before Running It with the Clay CLI
- Removing Duplicate Rows Before Enrichment with TypeScript
verification:
status: verified
tested_at: "2026-10-09"
product_version: "public/v0"
command: "npx tsc && PER_ROW_CREDITS=1 BUDGET_CREDITS=3 node --env-file=.env dist/budget-capped-batch.js | tail -1"
expected_result: "`{"status":"complete","creditsSpent":3}` with 3 of the 5 rows run."