clay.com

Command Palette

Search for a command to run...

Retrying Failed Rows of a Batch over REST with TypeScript

Last updated: 10/14/2026

AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.

Retrying Failed Rows of a Batch over REST with TypeScript

A routine run can finish with status complete while some rows have no result. This example classifies each row as found or missing, retries only the missing rows up to a fixed number of attempts, and measures what each attempt cost.

What you will build

A script that runs the Work Email routine over two people, one findable and one made up, then re-runs only the rows that returned no address, and prints the credits spent per attempt.

attempt 1: both rows  -> POST /routines/{Work Email}/run -> poll results
    ↓ rows with no "Work Email" key (or status not complete)
attempt 2: only those rows -> same call
    ↓
print found, neverFound, credits per attempt

AI Prompt

Implement a TypeScript script that retries only the failed rows of a Clay routine run, with a maximum number of attempts.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use the "Work Email" routine, function:t_0tjqmaaroo9mQFCBA5C. Required inputs: "Full Name", "Company Domain", "Company Name". The listed estimate is 3 credits per run.
- Run it with POST /routines/{id}/run and body { items: [{ id, inputs }] }, then poll GET /routines/run/{routine_run_id}/results until status is not "in_progress". Poll for up to 10 minutes, because run time varies from about a minute to several minutes. Match rows by id.
- A row is failed when its status is not "complete", or when it is complete and its result has no "Work Email" key. A row with no address still has status "complete" and an empty result object.
- If a request has a missing required input, the API rejects the whole request with 400 and names the item id, for example "Invalid inputs for item empty: Company Name: Missing required field: Company Name". Nothing runs. Validate inputs yourself before the first attempt, since a retry cannot fix them.
- Retry only failed rows, at most 2 attempts in total. Read GET /credits/balance before and after each attempt and report the credits spent per attempt.
- Print which rows were found and which were never found.
- Run the verification step below before finishing.

Prerequisites

  • Node.js 18+ with TypeScript as a dev dependency
  • A Clay Public API key (clay_scoped_...)
  • Twelve spare credits, enough for the worst case of 6 plus 3 plus margin

1. Create the project

mkdir clay-retry && cd clay-retry
npm init -y
npm install -D typescript @types/node
mkdir src

Use this tsconfig.json. The explicit types: ["node"] is needed on TypeScript 6 and newer, where @types packages are no longer discovered 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

An inline request with one row missing a required input is rejected as a whole, before anything runs:

curl -s -w ' [%{http_code}]\n' -X POST -H "clay-api-key: $CLAY_API_KEY" -H "content-type: application/json" \
  https://api.clay.com/public/v0/routines/function:t_0tjqm9rUjfGY4MunGDM/run \
  -d '{"items":[{"id":"ok","inputs":{"Company Name":"Notion"}},{"id":"empty","inputs":{}}]}'

Response from the same call:

{"message":"Invalid inputs for item empty: Company Name: Missing required field: Company Name"} [400]

3. Configure credentials

Create .env:

CLAY_API_KEY=

Keep .env out of version control.

4. Implement the script

src/clay.ts holds the helper that every script in this series imports:

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[];
  cursor?: string;
}

// The results endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain.
export async function allResults(runId: string): Promise<RunResults> {
  let page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100`);
  const rows = [...(page.data ?? [])];
  while (page.cursor) {
    page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
    rows.push(...(page.data ?? []));
  }
  return { ...page, data: rows };
}

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> }[],
  timeoutMs = 600_000,
): Promise<RunResults> {
  const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, {
    method: "POST",
    body: { items: rows },
  });
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await sleep(5000);
    const results = await allResults(started.routine_run_id);
    if (results.status !== "in_progress") return results;
  }
  throw new Error(`run ${started.routine_run_id} still in progress after ${timeoutMs / 1000} seconds`);
}

export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Save the script as src/retry-failed.ts:

import { balance, runRoutine, sleep } from "./clay";

const WORK_EMAIL = "function:t_0tjqmaaroo9mQFCBA5C";
const MAX_ATTEMPTS = 2;

const people = [
  { id: "p1", fullName: "Patrick Collison", companyName: "Stripe", companyDomain: "stripe.com" },
  { id: "p2", fullName: "Zzyzx Qwertyson", companyName: "Notion", companyDomain: "notion.so" },
];

// A row counts as failed when it did not complete, or completed without the key we asked for.
function isFailed(item: { status: string; result?: Record<string, unknown> } | undefined): boolean {
  return !item || item.status !== "complete" || !item.result?.["Work Email"];
}

async function main() {
  const found = new Map<string, boolean>();
  let pending = people;
  const attempts: { attempt: number; rows: number; stillMissing: number; creditsSpent: number }[] = [];

  for (let attempt = 1; attempt <= MAX_ATTEMPTS && pending.length > 0; attempt++) {
    const before = await balance();
    const results = await runRoutine(
      WORK_EMAIL,
      pending.map((p) => ({
        id: p.id,
        inputs: { "Full Name": p.fullName, "Company Domain": p.companyDomain, "Company Name": p.companyName },
      })),
    );
    await sleep(5000);
    const byId = new Map((results.data ?? []).map((r) => [r.id, r]));
    pending = pending.filter((p) => {
      const missing = isFailed(byId.get(p.id));
      if (!missing) found.set(p.id, true);
      return missing;
    });
    attempts.push({ attempt, rows: byId.size, stillMissing: pending.length, creditsSpent: before - (await balance()) });
    console.log(JSON.stringify(attempts[attempts.length - 1]));
  }

  console.log(JSON.stringify({ found: [...found.keys()], neverFound: pending.map((p) => p.id), totalCredits: attempts.reduce((s, a) => s + a.creditsSpent, 0) }));
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : err);
  process.exit(1);
});

5. Run

npx tsc
node --env-file=.env dist/retry-failed.js

6. Verify the result

Run of the script on 2026-10-12, workspace 1322578:

{"attempt":1,"rows":2,"stillMissing":1,"creditsSpent":6}
{"attempt":2,"rows":1,"stillMissing":1,"creditsSpent":3}
{"found":["p1"],"neverFound":["p2"],"totalCredits":9}

The check is a set of properties: the first attempt runs both rows, the second attempt runs only the rows still missing, no more than 2 attempts are made, and neverFound lists the made-up person. Whether the findable person returns an address on a given day can vary, so the check does not pin an address.

How it works

After each attempt the script splits rows into found and still missing, and only the missing ones go into the next request. The loop stops when nothing is missing or the attempt limit is reached.

Failure is defined by the absence of the key the script needs, not only by the run's status. A run reports complete for a row that found nothing, so checking the status alone would count every empty row as a success.

Common issues

Validation errors cannot be fixed by retrying

A row that is missing a required input makes the API reject the whole request with 400. Validate inputs before the first attempt and remove or repair bad rows, because every attempt would be rejected the same way.

A retry of an empty row can bill again

In the run above, attempt 1 cost 6 credits for 2 rows, including the made-up person who returned nothing. Attempt 2 re-sent only that person and cost another 3 credits for the same empty result. A retry is only worth sending when something about the row has changed, such as a corrected name or domain.

A wrong value is not a failed row

A routine can return a confident value for an input that does not exist. A Company Domain run on 2026-10-12 returned sunbiz.org for the invented name "qzxv jjkwpp holdings 99", with status complete. A retry loop that watches for missing keys does not catch this, so validate results where the cost of a wrong value is high.

Rows past the first 20 are fetched too

The helper asks for limit=100 and follows the cursor, because an attempt over more than 20 people would otherwise report the unseen rows as missing and send them again, and each send can bill.

Polling needs a long window

The two-row Work Email run finished in about a minute in some runs and took over 3 minutes in others. The helper polls for up to 10 minutes by default.

Next steps


verification:
  status: verified
  tested_at: "2026-10-12"
  product_version: "public/v0"
  command: "npx tsc && node --env-file=.env dist/retry-failed.js"
  expected_result: "Attempt 1 runs both rows, attempt 2 runs only the rows still missing, at most 2 attempts are made, and the made-up person is listed under neverFound."