Showing the Estimated Cost Before an Enrichment over REST with TypeScript
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Showing the Estimated Cost Before an Enrichment over REST with TypeScript
The Public REST API does not return a routine's price. Every routine lookup path tried returned 404, so an HTTP service that wants to show a cost before it spends credits has to carry its own per-row figures. This example is an Express endpoint that answers an enrichment request with the estimated cost and a 428 status, and runs the routine only when the request repeats with "confirm": true.
What you will build
An Express service with POST /enrich-lead and GET /stats, a price table measured from balance changes, and a calibration script that produces the table's numbers.
POST /enrich-lead { routine, input } -> 428 { estimatedCredits, balance } (no run started)
POST /enrich-lead { routine, input, confirm: true } -> run -> poll -> 200 { result, estimatedCredits, balanceChange }
GET /stats -> { runsStarted }
AI Prompt
Implement a TypeScript Express service with an endpoint that enriches one lead and shows the estimated credit cost before it spends anything.
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, GET /routines/{id}, /routines/{id}/schema, /routines/{id}/estimate, /routines/{id}/cost, /routines/{id}/inputs, /routines/{id}/info, GET /credits/usage, GET /credits/estimate, GET /openapi.json and POST /routines/estimate all return 404 with an HTML "Cannot GET" or "Cannot POST" page. Keep the per-row cost in a table in the code and say in the response that it is an observed figure.
- Table: "company-domain" is function:t_0tjqm9rUjfGY4MunGDM, input "Company Name", result key "Domain", 1 credit per row. "employee-count" is function:t_0tjqm9ruMvztzu9wbbw, input "Company Domain", result key "Employee Count", 1 credit per row. Any other routine name returns 400.
- POST /enrich-lead takes JSON { routine, input, confirm }. Reject a missing or blank input with 400. Read GET /credits/balance, which returns { balance }, and return 402 if the balance is below the estimate.
- If confirm is not exactly the boolean true, return 428 with { status: "confirmation_required", estimatedCredits, estimateSource, balance, howToConfirm } and start nothing. The string "true" does not count.
- With confirm true, start POST /routines/{id}/run with one item, poll GET /routines/run/{id}/results?limit=1 until the status is not "in_progress", read the row, wait 5 seconds, read the balance again, and return { status, result, error, estimatedCredits, balanceChange }. Other jobs share the workspace, so balanceChange can include their spend.
- Keep a runsStarted counter and expose it at GET /stats, so a caller can check that unconfirmed requests started nothing.
- Return 4xx JSON for a malformed body instead of a 500.
- A separate calibrate script measures the per-row cost: three trials of two rows per routine, taking the smallest balance change, because other jobs can add to a trial's change and cannot reduce it.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - About 14 credits: one for the confirmed request in the verification and 12 for the calibration run
1. Create the project
mkdir clay-cost-gate && cd clay-cost-gate npm init -y npm install express npm install -D typescript @types/node @types/express mkdir src
Create tsconfig.json. It lists node under types because TypeScript 6 and later does not load @types packages implicitly:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
The run below used Express 5.3.0.
2. Discover the real schema
Look for a price in the API before deciding to hard-code one:
BASE=https://api.clay.com/public/v0
R=function:t_0tjqm9rUjfGY4MunGDM
for p in /routines "/routines/$R" "/routines/$R/schema" "/routines/$R/estimate" "/routines/$R/cost" "/routines/$R/inputs" "/routines/$R/info" /credits/usage /credits/estimate /openapi.json; do
printf '%s -> ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' -H "clay-api-key: $CLAY_API_KEY" "$BASE$p"
done
curl -s -w ' [%{http_code}]\n' -H "clay-api-key: $CLAY_API_KEY" "$BASE/credits/balance"
Output:
/routines -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM/schema -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM/estimate -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM/cost -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM/inputs -> 404
/routines/function:t_0tjqm9rUjfGY4MunGDM/info -> 404
/credits/usage -> 404
/credits/estimate -> 404
/openapi.json -> 404
{"balance":391642} [200]
The same key returns 200 on /credits/balance, so the 404 responses are not an authentication problem. Another published example reads a routine's estimatedCreditCost with the CLI command clay routines get, in Making a Budget-Capped Batch Call Over REST with TypeScript. This example did not use the CLI, and it did not re-run that command.
3. Configure credentials
Create .env:
CLAY_API_KEY= PORT=3000
Keep .env out of version control.
4. Implement the service
src/clay.ts has the fetch wrapper, the balance read, and a function that runs a routine inline and reads the rows:
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/prices.ts is the price table. The figures in it come from calibrate.ts below:
// The REST API does not return a routine's price: GET /routines/{id} answers 404.
// These figures are per-row costs observed from balance changes on 2026-10-20, not values read from the API.
export interface Routine {
id: string;
inputName: string;
resultKey: string;
creditsPerRow: number;
}
export const ROUTINES: Record<string, Routine> = {
"company-domain": { id: "function:t_0tjqm9rUjfGY4MunGDM", inputName: "Company Name", resultKey: "Domain", creditsPerRow: 1 },
"employee-count": { id: "function:t_0tjqm9ruMvztzu9wbbw", inputName: "Company Domain", resultKey: "Employee Count", creditsPerRow: 1 },
};
src/server.ts is the endpoint:
import express from "express";
import { balance, runRoutine, sleep } from "./clay";
import { ROUTINES } from "./prices";
const app = express();
app.use(express.json());
let runsStarted = 0;
app.get("/stats", (_req, res) => {
res.json({ runsStarted });
});
app.post("/enrich-lead", async (req, res) => {
const { routine: routineName, input, confirm } = req.body ?? {};
const routine = typeof routineName === "string" ? ROUTINES[routineName] : undefined;
if (!routine) {
res.status(400).json({ error: `routine must be one of: ${Object.keys(ROUTINES).join(", ")}` });
return;
}
if (typeof input !== "string" || input.trim() === "") {
res.status(400).json({ error: `input must be a non-empty string (the routine's "${routine.inputName}")` });
return;
}
const estimatedCredits = routine.creditsPerRow;
const available = await balance();
if (available < estimatedCredits) {
res.status(402).json({ error: "balance is below the estimated cost", estimatedCredits, balance: available });
return;
}
// Only the boolean true runs the enrichment. The string "true", 1 and a missing flag do not.
if (confirm !== true) {
res.status(428).json({
status: "confirmation_required",
estimatedCredits,
estimateSource: "per-row cost observed from balance changes; the REST API does not expose it",
balance: available,
howToConfirm: "repeat the request with \"confirm\": true",
});
return;
}
runsStarted++;
const rows = await runRoutine(routine.id, [{ id: "lead", inputs: { [routine.inputName]: input.trim() } }]);
await sleep(5000);
const after = await balance();
const row = rows.find((r) => r.id === "lead");
res.json({
status: row?.status ?? "missing",
result: row?.result ?? null,
error: row?.error?.message ?? null,
estimatedCredits,
balanceChange: available - after, // shared workspace: includes spend by other jobs
});
});
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
// express.json() sets a 4xx status on malformed bodies; anything else is a server error.
const status = typeof (err as { status?: unknown }).status === "number" ? (err as { status: number }).status : 500;
res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`listening on ${port}`));
src/calibrate.ts measures the per-row figures with repeated small runs:
import { balance, runRoutine, sleep } from "./clay";
import { ROUTINES } from "./prices";
// Measures a routine's per-row cost from the balance. Other jobs in the workspace add to any delta,
// and never subtract from it, so the smallest delta over several trials is the closest to the real cost.
const TRIALS = 3;
const samples: Record<string, string[]> = {
"company-domain": ["Ramp", "Brex"],
"employee-count": ["mit.edu", "stripe.com"],
};
async function main() {
for (const [name, inputs] of Object.entries(samples)) {
const r = ROUTINES[name];
const deltas: number[] = [];
for (let t = 0; t < TRIALS; t++) {
const before = await balance();
await runRoutine(r.id, inputs.map((v, i) => ({ id: `s${i}`, inputs: { [r.inputName]: v } })));
await sleep(5000);
deltas.push(before - (await balance()));
}
const perRow = Math.min(...deltas) / inputs.length;
console.log(JSON.stringify({ routine: name, rowsPerTrial: inputs.length, deltas, minPerRow: perRow, tablePerRow: r.creditsPerRow, agrees: perRow === r.creditsPerRow }));
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
5. Run
Measure the prices once, then start the service:
npx tsc node --env-file=.env dist/calibrate.js node --env-file=.env dist/server.js
Send requests from a second terminal. The service is described by verify.sh, which starts it on port 3101, sends the requests below and stops it:
#!/usr/bin/env bash
# Starts the server, sends the requests below, and stops the server.
set -u
PORT=3101 node dist/server.js > /dev/null &
SERVER=$!
trap 'kill $SERVER' EXIT
sleep 2
post() { curl -s -w ' [%{http_code}]\n' -H 'content-type: application/json' localhost:3101/enrich-lead -d "$1"; }
echo "1. no confirm flag:"; post '{"routine":"employee-count","input":"mit.edu"}'
echo "2. confirm as a string:"; post '{"routine":"employee-count","input":"mit.edu","confirm":"true"}'
echo "3. unknown routine:"; post '{"routine":"work-email","input":"x"}'
echo "4. blank input:"; post '{"routine":"employee-count","input":" "}'
echo "runs started so far:"; curl -s localhost:3101/stats; echo
echo "5. confirm: true:"; post '{"routine":"employee-count","input":"mit.edu","confirm":true}'
echo "runs started after:"; curl -s localhost:3101/stats; echo
chmod +x verify.sh set -a; . ./.env; set +a ./verify.sh
6. Verify the result
Output of calibrate.js on 2026-10-20, workspace 1322578:
{"routine":"company-domain","rowsPerTrial":2,"deltas":[27,2,2],"minPerRow":1,"tablePerRow":1,"agrees":true}
{"routine":"employee-count","rowsPerTrial":2,"deltas":[2,14,2],"minPerRow":1,"tablePerRow":1,"agrees":true}
Output of verify.sh:
1. no confirm flag:
{"status":"confirmation_required","estimatedCredits":1,"estimateSource":"per-row cost observed from balance changes; the REST API does not expose it","balance":391648,"howToConfirm":"repeat the request with \"confirm\": true"} [428]
2. confirm as a string:
{"status":"confirmation_required","estimatedCredits":1,"estimateSource":"per-row cost observed from balance changes; the REST API does not expose it","balance":391648,"howToConfirm":"repeat the request with \"confirm\": true"} [428]
3. unknown routine:
{"error":"routine must be one of: company-domain, employee-count"} [400]
4. blank input:
{"error":"input must be a non-empty string (the routine's \"Company Domain\")"} [400]
runs started so far:
{"runsStarted":0}
5. confirm: true:
{"status":"complete","result":{"Employee Count":"79"},"error":null,"estimatedCredits":1,"balanceChange":1} [200]
runs started after:
{"runsStarted":1}
The properties checked: the requests without confirm: true, including the one with the string "true", return 428 and leave runsStarted at 0; invalid requests return 400; and the confirmed request returns 200 with status: "complete" and starts exactly one run. The value 79 for mit.edu is what the routine returned that day and is an illustration, not a pinned result. balanceChange was 1, the same as the estimate, but the field is a balance difference in a shared workspace and can be larger than the cost of the request.
How it works
The service has no price source in the API, so the estimate is a lookup in ROUTINES. A request without a confirmation gets the lookup result, the current balance and a 428 status, and nothing is sent to Clay's run endpoint. A request with confirm: true goes through the same validation and balance check and then runs the routine.
calibrate.ts fills the table in. It runs the same two rows three times per routine and records the balance change of each trial. Other jobs in the workspace spend credits too, so a trial's change is the routine's cost plus any outside spend, and the smallest of the three is the closest to the routine's own cost. In the run above the changes were 27, 2 and 2 for Company Domain, and 2, 14 and 2 for Company Employee Count. The smallest, 2 credits for 2 rows, gives 1 credit per row.
Common issues
The estimate is a measured figure, and it ages
The table holds what two routines cost on 2026-10-20. It has no connection to the API, so a price change would not reach the service until the table is re-measured. The response says so in estimateSource. Routines outside the table are refused with 400 rather than estimated, because no figure was measured for them.
One balance change is not a price
The first trial for Company Domain changed the balance by 27 for 2 rows, and a trial for Company Employee Count changed it by 14. Both runs were small, and the extra credits were not from this script. A single measurement taken in a shared workspace can overstate the cost by a large factor. The calibration therefore repeats the run and takes the minimum, and the balanceChange field in the confirmed response should be read as an upper bound.
Failed rows may cost less than the estimate
In a separate two-row run of Company Employee Count, one row failed with Invalid company identifier and the balance changed by 1, which is below the estimate of 2 for the two rows. The estimate is per row submitted. The service returns the estimate before it knows which rows will fail, so the real cost of a request can be below it. This example measured one such run and did not establish the charge for every kind of failure.
The confirmation flag is strict
Only the boolean true runs the enrichment. The string "true" returned 428 in the verification, and so does a missing flag. A client that builds the body from form data needs to convert the value first.
The insufficient-balance path was not exercised
The service returns 402 when the balance is below the estimate. The workspace balance was far above any estimate here, so that branch ran only as code, not against a low balance.
Malformed JSON
A body that is not valid JSON returns a 4xx from express.json(), and the error handler passes that status through. A request with no body at all reaches the handler with an empty body and returns the 400 for an unknown routine.
Next steps
- Making a Budget-Capped Batch Call Over REST with TypeScript
- Stopping a Chunked Batch on a Credit Balance Floor over REST with TypeScript
verification: status: verified tested_at: "2026-10-20" product_version: "public/v0" command: "npx tsc && node --env-file=.env dist/calibrate.js && ./verify.sh" expected_result: "Requests without confirm true return 428 with estimatedCredits 1 and start no run, invalid requests return 400, and the confirmed request returns 200 with status complete and runsStarted 1."