Stopping a Chunked Batch on a Credit Balance Floor over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Stopping a Chunked Batch on a Credit Balance Floor over REST with TypeScript
A long enrichment job can be split into small chunks, with a read of GET /credits/balance before each one. This script stops sending chunks when the balance has fallen by a set amount since it started, or when the balance is at or below a floor. It needs no price for the routine, and because it watches the workspace balance, it also reacts to credits spent by other jobs.
What you will build
A script that processes a list of eight companies two at a time with Company Domain. Before every chunk it reads the balance and applies two stop rules, then it prints which rows were never sent so that a later run can resume from them.
start = GET /credits/balance
for each chunk of CHUNK_SIZE rows:
current = GET /credits/balance
start - current >= MAX_DROP -> stop, exit 3
current <= FLOOR -> stop, exit 3
POST /routines/{id}/run (the chunk) -> poll -> read rows
wait 5 seconds
print stopReason, rows sent, rows remaining
exit 0 only when every chunk was sent
AI Prompt
Implement a TypeScript script that sends a Clay routine batch in chunks and stops when the credit balance falls too far.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- GET /credits/balance returns { "balance": <number> }. Read it once at the start and again before every chunk, including the first.
- Settings from the environment: CHUNK_SIZE (default 2, at least 1), MAX_DROP (stop when start balance minus current balance is at least this; default none) and FLOOR (stop when the balance is at or below this; default 0). Reject a value that is not a number of 0 or more with a message, before any request that spends credits, and exit 1.
- Use Company Domain, function:t_0tjqm9rUjfGY4MunGDM, with input "Company Name". Each chunk is one POST /routines/{id}/run request with ids that are unique across chunks. Poll GET /routines/run/{id}/results?limit=1 until the status is not in_progress, then read all rows with limit=100 and the cursor.
- The workspace is shared, so the balance can fall because of other jobs. The drop is measured on the whole workspace and is not only this script's spend. Do not assume the drop equals 1 credit per row.
- ROW_LIMIT optionally shortens the list of companies; the default is all 8.
- After each chunk wait 5 seconds before the next balance read.
- When a stop rule fires, print the stop reason, how many rows were sent, and the rows not sent, then exit with code 3. Exit 0 when every chunk was sent and 1 on an error.
- Record whether the balance ever rose between reads. Credits are only spent in this workflow, so a rise would mean the reads are not comparable.
- This is not a per-row budget: do not use a price per row, and do not trim the list before the first chunk.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - Up to about 20 credits for the five scenarios below
1. Create the project
mkdir clay-stop-loss && cd clay-stop-loss npm init -y npm install -D typescript @types/node mkdir src
The compiler options below include types: ["node"], which TypeScript 6 and later needs because it stopped loading @types packages automatically:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
2. Discover the real schema
The balance endpoint returns one number, and it is shared by everything that uses the workspace. Six reads at about five-second intervals, with no other requests from this script, show the balance moving on its own:
for i in 1 2 3 4 5 6; do curl -s -H "clay-api-key: $CLAY_API_KEY" https://api.clay.com/public/v0/credits/balance | jq -c . sleep 5 done
Output on 2026-10-20:
{"balance":391638}
{"balance":391638}
{"balance":391638}
{"balance":391638}
{"balance":391637}
{"balance":391637}
The balance fell by 1 during a window in which this script sent nothing. A separate 40-second series taken the same day fell by 1 at about 21 seconds and again at about 41 seconds. That background movement is why the script applies a threshold to the drop and does not look for an exact count.
3. Configure credentials
Create .env:
CLAY_API_KEY=
Keep .env out of version control.
4. Implement the script
src/clay.ts is the fetch wrapper, the balance read and a function that runs one chunk:
const BASE = "https://api.clay.com/public/v0";
export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const key = process.env.CLAY_API_KEY;
if (!key) throw new Error("CLAY_API_KEY is not set");
const res = await fetch(`${BASE}${path}`, {
method: init.method ?? "GET",
headers: { "clay-api-key": key, "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 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function balance(): Promise<number> {
return (await clay<{ balance: number }>("/credits/balance")).balance;
}
export interface RunRow {
id: string;
status: string;
result?: Record<string, unknown>;
error?: { message: string };
}
export interface RunPage {
status: string;
total: number;
finished: number;
data?: RunRow[];
cursor?: string;
}
// Starts an inline run and reads every page of the finished result.
export async function runRoutine(routineId: string, items: { id: string; inputs: Record<string, unknown> }[]): Promise<RunRow[]> {
const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, { method: "POST", body: { items } });
const path = `/routines/run/${started.routine_run_id}/results`;
for (let i = 0; i < 100; i++) {
const head = await clay<RunPage>(`${path}?limit=1`);
if (head.status !== "in_progress") break;
await sleep(3000);
}
const rows: RunRow[] = [];
let page = await clay<RunPage>(`${path}?limit=100`);
rows.push(...(page.data ?? []));
while (page.cursor) {
page = await clay<RunPage>(`${path}?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
rows.push(...(page.data ?? []));
}
return rows;
}
src/stop-loss.ts holds the loop and the two stop rules:
import { balance, runRoutine, sleep } from "./clay";
const COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM";
const allCompanies = ["Datadog", "Snowflake", "Okta", "Twilio", "Shopify", "HubSpot", "Canva", "Miro"];
const companies = allCompanies.slice(0, Number(process.env.ROW_LIMIT ?? allCompanies.length));
interface Settings {
chunkSize: number;
maxDrop: number; // stop when the balance has fallen by this much since the script started
floor: number; // stop when the balance is at or below this value
}
function readSettings(): Settings {
const num = (name: string, fallback: number) => {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) throw new Error(`${name} must be a number of 0 or more, got "${raw}"`);
return n;
};
const chunkSize = num("CHUNK_SIZE", 2);
if (chunkSize < 1) throw new Error("CHUNK_SIZE must be at least 1");
return { chunkSize, maxDrop: num("MAX_DROP", Infinity), floor: num("FLOOR", 0) };
}
async function main() {
const s = readSettings();
const start = await balance();
const log: { chunk: number; balanceBefore: number; dropSoFar: number }[] = [];
let last = start;
let balanceNeverIncreased = true;
let sent = 0;
let completeRows = 0;
let stopReason: "completed" | "drop cap reached" | "balance at or below floor" = "completed";
for (let offset = 0, chunk = 1; offset < companies.length; offset += s.chunkSize, chunk++) {
// The check runs before every chunk, including the first, so a balance that is already too low sends nothing.
const current = await balance();
if (current > last) balanceNeverIncreased = false;
last = current;
const drop = start - current;
log.push({ chunk, balanceBefore: current, dropSoFar: drop });
console.log(JSON.stringify({ check: chunk, balance: current, dropSoFar: drop, maxDrop: s.maxDrop === Infinity ? null : s.maxDrop, floor: s.floor }));
if (drop >= s.maxDrop) { stopReason = "drop cap reached"; break; }
if (current <= s.floor) { stopReason = "balance at or below floor"; break; }
const rows = companies.slice(offset, offset + s.chunkSize).map((name, i) => ({ id: `r${offset + i}`, inputs: { "Company Name": name } }));
const results = await runRoutine(COMPANY_DOMAIN, rows);
sent += rows.length;
completeRows += results.filter((r) => r.status === "complete").length;
// Pause so the next balance read is less likely to come before this chunk's charge is applied. The lag was not measured.
await sleep(5000);
}
const remaining = companies.slice(sent);
console.log(JSON.stringify({ stopReason, sent, completeRows, remaining: remaining.length, remainingRows: remaining, balanceNeverIncreased, finalDrop: start - (await balance()) }));
process.exit(stopReason === "completed" ? 0 : 3);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
5. Run
npx tsc B=$(curl -s -H "clay-api-key: $CLAY_API_KEY" https://api.clay.com/public/v0/credits/balance | jq .balance) MAX_DROP=3 node --env-file=.env dist/stop-loss.js; echo "exit=$?" FLOOR=$((B - 3)) node --env-file=.env dist/stop-loss.js; echo "exit=$?" ROW_LIMIT=4 MAX_DROP=1000 FLOOR=0 node --env-file=.env dist/stop-loss.js; echo "exit=$?" FLOOR=$((B + 1000000)) node --env-file=.env dist/stop-loss.js; echo "exit=$?" MAX_DROP=abc node --env-file=.env dist/stop-loss.js; echo "exit=$?"
6. Verify the result
Output on 2026-10-20, workspace 1322578. First, MAX_DROP=3:
{"check":1,"balance":391675,"dropSoFar":0,"maxDrop":3,"floor":0}
{"check":2,"balance":391665,"dropSoFar":10,"maxDrop":3,"floor":0}
{"stopReason":"drop cap reached","sent":2,"completeRows":2,"remaining":6,"remainingRows":["Okta","Twilio","Shopify","HubSpot","Canva","Miro"],"balanceNeverIncreased":true,"finalDrop":10}
exit=3
FLOOR set 3 below the balance read just before the run:
{"check":1,"balance":391696,"dropSoFar":0,"maxDrop":null,"floor":391693}
{"check":2,"balance":391685,"dropSoFar":11,"maxDrop":null,"floor":391693}
{"stopReason":"balance at or below floor","sent":2,"completeRows":2,"remaining":6,"remainingRows":["Okta","Twilio","Shopify","HubSpot","Canva","Miro"],"balanceNeverIncreased":true,"finalDrop":11}
exit=3
Control with limits that are not reached, using 4 rows:
{"check":1,"balance":391685,"dropSoFar":0,"maxDrop":1000,"floor":0}
{"check":2,"balance":391678,"dropSoFar":7,"maxDrop":1000,"floor":0}
{"stopReason":"completed","sent":4,"completeRows":4,"remaining":0,"remainingRows":[],"balanceNeverIncreased":true,"finalDrop":10}
exit=0
A floor set a million credits above the balance:
{"check":1,"balance":391675,"dropSoFar":0,"maxDrop":null,"floor":1391696}
{"stopReason":"balance at or below floor","sent":0,"completeRows":0,"remaining":8,"remainingRows":["Datadog","Snowflake","Okta","Twilio","Shopify","HubSpot","Canva","Miro"],"balanceNeverIncreased":true,"finalDrop":0}
exit=3
A setting that is not a number:
MAX_DROP must be a number of 0 or more, got "abc" exit=1
The checks are properties of the loop. With a limit that is crossed, the exit code is 3, the stop reason names the rule that fired, and the rows not sent are listed. With limits that are not reached the exit code is 0 and every row is sent. With a floor that is already crossed, nothing is sent. balanceNeverIncreased was true in every run. The balance numbers above are what the workspace held on that day.
How it works
The loop reads the balance before each chunk and compares it with two values: the balance at the start, for the drop, and a fixed floor. Either rule ends the loop before the next chunk is sent, so the most the script can overshoot is the cost of one chunk plus whatever other jobs spend in the same interval. The first check happens before any row is sent, which is why a floor above the current balance sends nothing.
Both rules use the live balance. The script does not know how much a chunk costs and does not need to. This is the difference from capping a batch by dividing a budget by a per-row price, as in Making a Budget-Capped Batch Call Over REST with TypeScript. That script decides the number of rows once, before the run, from an estimate. This one decides after every chunk, from what the balance shows, and no estimate is involved.
Common issues
The drop includes other jobs
Sending 2 rows of Company Domain moved the balance by 10 in the first scenario and by 11 in the second. The routine costs about 1 credit per row, and the rest came from other work in the workspace. The cap and the floor therefore act on the workspace balance. A stop on drop cap reached can mean that a different job is spending. Use a cap larger than the batch's own cost when the stop should depend on the script's rows alone, or run the script in a workspace that nothing else uses.
The script can overshoot by one chunk
The rules are checked before a chunk and not during it. A chunk that starts just under the cap is still sent in full. A smaller CHUNK_SIZE lowers the overshoot and adds one balance read and one run request per chunk.
The balance read may come before the charge
The script waits 5 seconds after each chunk before the next read. The delay between a run finishing and the balance reflecting its cost was not measured in this example, so 5 seconds is a choice and not a measured bound. A cap close to the cost of a single chunk can fire one chunk late if the charge arrives after the read.
Remaining rows are printed and not stored
When the script stops it prints the companies it did not send. It does not write them to a file or keep any state, and re-running it starts from the first row again. Passing the printed rows to a resume step is left to the caller; Retrying Failed Rows of a Batch over REST with TypeScript handles the failed-row side of that.
A cap of 0 stops before the first chunk
MAX_DROP=0 is satisfied immediately because the drop at the first check is 0 or more, so no row is sent. This was not run; it follows from the comparison drop >= maxDrop in the code.
The scenarios use one routine
Only Company Domain was used, with two rows per chunk. Chunk sizes other than 2 and routines that cost more per row were not run.
Next steps
- Making a Budget-Capped Batch Call Over REST with TypeScript
- Watching a Stalled Run and Raising an Alert over REST with TypeScript
verification: status: verified tested_at: "2026-10-20" product_version: "public/v0" command: "npx tsc && MAX_DROP=3 node --env-file=.env dist/stop-loss.js; echo exit=$?" expected_result: "The script stops at the second balance check, after one chunk of 2 rows, with stopReason `drop cap reached`, lists the 6 rows not sent, reports balanceNeverIncreased true, and exits 3. With limits that are not reached it sends every row and exits 0."