clay.com

Command Palette

Search for a command to run...

Previewing Credit Cost Before a Run over REST with Python

Last updated: 10/20/2026

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

Previewing Credit Cost Before a Run over REST with Python

The REST API has no endpoint that returns a routine's schema or price, so a cost preview has to come from somewhere else. This example derives a per-row figure from small calibration runs, prints an estimate for a CSV before anything is sent, and refuses to run unless --yes is passed.

What you will build

A script, preflight.py, with four commands. discover-inputs finds a routine's required input name without spending credits. calibrate runs a few single-row probes and records the balance change of each in prices.json. preview reads a CSV and prints rows, per-row cost, estimated total and current balance. run prints the same preview and sends rows only when --yes is present and the estimate fits both --max-credits and the balance.

GET /routines/{id}, /schema, /estimate     ->  404 (nothing to read a price from)
POST /run with empty inputs                ->  400 naming the required field (free)
calibrate: balance, 1-row run, balance x N ->  prices.json (smallest delta wins)
preview:   CSV rows x per_row, vs balance  ->  printed, nothing sent
run:       --yes and fits the limits       ->  POST /run, poll, report

AI Prompt

Implement a Python script that previews the credit cost of a Clay routine run and refuses to run without explicit confirmation.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- GET /routines, GET /routines/{id}, /routines/{id}/schema and /routines/{id}/estimate all return 404, so no price or input schema can be read. Derive the per-row cost from calibration runs and say so in the output.
- POST /routines/{id}/run with { items: [{ id, inputs: {} }] } returns 400 with { message } naming the missing input, for example "Invalid inputs for item probe: Company Name: Missing required field: Company Name". Nothing runs and nothing is charged. An unknown routine id returns 404 { message: "Routine not found" }.
- GET /credits/balance returns { balance } and can lag a run by a few seconds, so wait about 6 seconds before reading it after a run.
- Balance deltas include credits spent by anything else in the workspace, which can only make a delta larger. Take the smallest delta across several single-row probes as the per-row figure and store every observed delta next to it.
- Results come from GET /routines/run/{routine_run_id}/results?limit=100 and follow the cursor when present. Each item is { id, status, result }.
- preview must send no POST /run request. Count POST /run requests in the HTTP helper and print the count.
- run must exit with code 2 and send no request when --yes is missing, when the estimate is above --max-credits, or when the balance is below the estimate. A missing column or a CSV with no non-empty values must exit before any request to run.
- Run the verification step below before finishing.

Prerequisites

  • Python 3.10+
  • pip install requests
  • A Clay Public API key (clay_scoped_...)
  • About 15 credits: the calibration probes and one 5-row run. The refusals and the previews spend nothing.

1. Set up the project

mkdir clay-preflight && cd clay-preflight
python3 -m venv .venv && source .venv/bin/activate
pip install requests

2. Discover the real schema

Check which read endpoints exist, using /me and /credits/balance as controls that the key and base URL are fine:

python3 probe_paths.py

Save as probe_paths.py:

import clay

R = "function:t_0tjqm9rUjfGY4MunGDM"
for path in ["/me", "/credits/balance", "/routines", f"/routines/{R}", f"/routines/{R}/schema", f"/routines/{R}/estimate"]:
    res = clay.clay(path, raw=True)
    print(res.status_code, path)

Real output:

200 /me
200 /credits/balance
404 /routines
404 /routines/function:t_0tjqm9rUjfGY4MunGDM
404 /routines/function:t_0tjqm9rUjfGY4MunGDM/schema
404 /routines/function:t_0tjqm9rUjfGY4MunGDM/estimate

The routine list, the routine detail and two guessed sub-paths all return 404, while the controls return 200. Other sub-paths were not tried, so this shows that these four are absent and does not show that no price is exposed anywhere.

The required input name can still be found, because a run request with empty inputs is validated before it starts:

python3 preflight.py discover-inputs function:t_0tjqm9rUjfGY4MunGDM
python3 preflight.py discover-inputs function:t_0tjqm9ruMvztzu9wbbw
python3 preflight.py discover-inputs function:t_nope

Real output:

{"status": 400, "message": "Invalid inputs for item probe: Company Name: Missing required field: Company Name"}
{"status": 400, "message": "Invalid inputs for item probe: Company Domain: Missing required field: Company Domain"}
{"status": 404, "message": "Routine not found"}

3. Configure credentials

export CLAY_API_KEY=clay_scoped_...

4. Implement the script

Save the helper as clay.py:

import os
import time

import requests

BASE = "https://api.clay.com/public/v0"

session = requests.Session()
session.headers.update({"clay-api-key": os.environ["CLAY_API_KEY"], "content-type": "application/json"})
run_requests_sent = 0  # POST /routines/{id}/run calls made by this process


def clay(path, method="GET", body=None, raw=False):
    global run_requests_sent
    if method == "POST" and path.endswith("/run"):
        run_requests_sent += 1
    res = session.request(method, f"{BASE}{path}", json=body, timeout=60)
    if raw:
        return res
    if not res.ok:
        raise RuntimeError(f"{res.status_code} {path}: {res.text}")
    return res.json()


def balance():
    return clay("/credits/balance")["balance"]


def all_results(run_id):
    page = clay(f"/routines/run/{run_id}/results?limit=100")
    rows = list(page.get("data", []))
    while page.get("cursor"):
        page = clay(f"/routines/run/{run_id}/results?limit=100&cursor={page['cursor']}")
        rows.extend(page.get("data", []))
    page["data"] = rows
    return page


def run_routine(routine_id, rows, timeout_s=600):
    started = clay(f"/routines/{routine_id}/run", "POST", {"items": rows})
    run_id = started["routine_run_id"]
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        time.sleep(4)
        results = all_results(run_id)
        if results["status"] != "in_progress":
            return results
    raise RuntimeError(f"run {run_id} still in progress after {timeout_s} seconds")

Save the script as preflight.py:

import argparse
import csv
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import clay

PRICES = Path("prices.json")


def load_prices():
    return json.loads(PRICES.read_text()) if PRICES.exists() else {}


def discover_inputs(routine_id):
    """An empty inputs object is rejected before anything runs; the 400 names the required field."""
    res = clay.clay(f"/routines/{routine_id}/run", "POST", {"items": [{"id": "probe", "inputs": {}}]}, raw=True)
    return res.status_code, res.json().get("message")


def settled_balance():
    time.sleep(6)  # the balance can lag the run by a few seconds
    return clay.balance()


def calibrate(routine_id, field, example, probes):
    """Run `probes` single-row runs and record the balance delta of each.
    Other activity can only add to a delta, so the smallest one is the tightest upper bound on the per-row cost."""
    deltas, result_keys = [], set()
    for i in range(probes):
        before = settled_balance()
        result = clay.run_routine(routine_id, [{"id": f"cal{i}", "inputs": {field: example}}])
        row = result["data"][0]
        if row["status"] != "complete":
            sys.exit(f"probe {i} did not complete: {row}")
        result_keys |= set((row.get("result") or {}).keys())
        deltas.append(before - settled_balance())
    prices = load_prices()
    prices[routine_id] = {"input_field": field, "per_row": min(deltas), "observed_deltas": deltas,
                          "probes": probes, "result_keys": sorted(result_keys), "calibrated_on": datetime.now(timezone.utc).date().isoformat()}
    PRICES.write_text(json.dumps(prices, indent=2))
    return prices[routine_id]


def read_rows(path, column):
    with open(path, newline="", encoding="utf-8") as fh:
        reader = csv.DictReader(fh)
        if column not in (reader.fieldnames or []):
            sys.exit(f"column {column!r} not in {reader.fieldnames}")
        values = [(r.get(column) or "").strip() for r in reader]
    values = [v for v in values if v]
    if not values:
        sys.exit("no non-empty values to run")
    return values


def preview(routine_id, path, column):
    entry = load_prices().get(routine_id)
    if not entry:
        sys.exit(f"no price for {routine_id}; run calibrate first")
    values = read_rows(path, column)
    est = len(values) * entry["per_row"]
    bal = clay.balance()
    info = {"routine": routine_id, "rows": len(values), "per_row": entry["per_row"],
            "observed_deltas": entry["observed_deltas"], "estimated_credits": est,
            "balance": bal, "enough_balance": bal >= est}
    print(json.dumps(info))
    return entry, values, est, bal


def main():
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="cmd", required=True)
    d = sub.add_parser("discover-inputs"); d.add_argument("routine")
    c = sub.add_parser("calibrate"); c.add_argument("routine"); c.add_argument("--field", required=True)
    c.add_argument("--example", required=True); c.add_argument("--probes", type=int, default=3)
    for name in ("preview", "run"):
        p = sub.add_parser(name); p.add_argument("routine"); p.add_argument("csv_file"); p.add_argument("--column", required=True)
        if name == "run":
            p.add_argument("--yes", action="store_true"); p.add_argument("--max-credits", type=float)
    a = ap.parse_args()

    if a.cmd == "discover-inputs":
        status, message = discover_inputs(a.routine)
        print(json.dumps({"status": status, "message": message}))
    elif a.cmd == "calibrate":
        print(json.dumps(calibrate(a.routine, a.field, a.example, a.probes)))
    elif a.cmd == "preview":
        preview(a.routine, a.csv_file, a.column)
        print(json.dumps({"run_requests_sent": clay.run_requests_sent}))
    else:
        entry, values, est, bal = preview(a.routine, a.csv_file, a.column)
        refusal = None
        if not a.yes:
            refusal = "refusing to run without --yes"
        elif a.max_credits is not None and est > a.max_credits:
            refusal = f"estimate {est} is above --max-credits {a.max_credits}"
        elif bal < est:
            refusal = f"balance {bal} is below the estimate {est}"
        if refusal:
            print(json.dumps({"refused": refusal, "run_requests_sent": clay.run_requests_sent}))
            sys.exit(2)
        before = bal
        rows = [{"id": str(i), "inputs": {entry["input_field"]: v}} for i, v in enumerate(values)]
        result = clay.run_routine(a.routine, rows)
        delta = before - settled_balance()
        print(json.dumps({"status": result["status"], "total": result["total"], "finished": result["finished"],
                          "row_statuses": sorted({r["status"] for r in result["data"]}),
                          "estimated_credits": est, "observed_delta": delta,
                          "observed_delta_includes_other_activity": True,
                          "run_requests_sent": clay.run_requests_sent}))


if __name__ == "__main__":
    main()

Test data: companies.csv has a company column with five names, and domains.csv has a domain column with three domains.

company
Notion
Figma
Linear
Vercel
Supabase
domain
notion.so
figma.com
linear.app

5. Run

python3 preflight.py calibrate function:t_0tjqm9rUjfGY4MunGDM --field "Company Name" --example Datadog
python3 preflight.py calibrate function:t_0tjqm9ruMvztzu9wbbw --field "Company Domain" --example datadoghq.com --probes 2
python3 preflight.py preview function:t_0tjqm9rUjfGY4MunGDM companies.csv --column company
python3 preflight.py run function:t_0tjqm9rUjfGY4MunGDM companies.csv --column company
python3 preflight.py run function:t_0tjqm9rUjfGY4MunGDM companies.csv --column company --yes --max-credits 3
python3 preflight.py run function:t_0tjqm9rUjfGY4MunGDM companies.csv --column company --yes

6. Verify the result

Tested live on 2026-10-20 against the real Clay API (workspace 1322578). Calibration, Company Domain first, then Company Employee Count:

{"input_field": "Company Name", "per_row": 1, "observed_deltas": [1, 1, 1], "probes": 3, "result_keys": ["Domain"], "calibrated_on": "2026-10-20"}
{"input_field": "Company Domain", "per_row": 1, "observed_deltas": [6, 1], "probes": 2, "result_keys": ["Employee Count"], "calibrated_on": "2026-10-20"}

Every probe row had status complete and returned the expected result key (Domain, Employee Count). The smallest delta was 1 for both routines. One probe of Company Employee Count showed a delta of 6, and an earlier calibration of Company Domain on the same day showed [1, 6, 1]. Each of those series also contains a delta of 1 for the same single-row call, so the larger delta includes credits spent by something other than the probe, which is the reason the script keeps the smallest delta. The source of the extra credits was not identified.

Preview, refusals and guards, in the order run:

$ preview domain
{"routine": "function:t_0tjqm9rUjfGY4MunGDM", "rows": 5, "per_row": 1, "observed_deltas": [1, 1, 1], "estimated_credits": 5, "balance": 391825, "enough_balance": true}
{"run_requests_sent": 0}
exit=0
$ preview headcount
{"routine": "function:t_0tjqm9ruMvztzu9wbbw", "rows": 3, "per_row": 1, "observed_deltas": [6, 1], "estimated_credits": 3, "balance": 391825, "enough_balance": true}
{"run_requests_sent": 0}
exit=0
$ run, no --yes
{"routine": "function:t_0tjqm9rUjfGY4MunGDM", "rows": 5, "per_row": 1, "observed_deltas": [1, 1, 1], "estimated_credits": 5, "balance": 391825, "enough_balance": true}
{"refused": "refusing to run without --yes", "run_requests_sent": 0}
exit=2
$ run, --yes --max-credits 3
{"routine": "function:t_0tjqm9rUjfGY4MunGDM", "rows": 5, "per_row": 1, "observed_deltas": [1, 1, 1], "estimated_credits": 5, "balance": 391825, "enough_balance": true}
{"refused": "estimate 5 is above --max-credits 3.0", "run_requests_sent": 0}
exit=2
$ run, uncalibrated routine
no price for function:t_0tjqm9rr9BS3GANGrb7; run calibrate first
exit=1

The preview commands report run_requests_sent: 0, and each refusal exits 2 with the same count. The uncalibrated routine exits 1 before any request to run. The wrong-column control appears in the next block.

The real run, with the wrong-column control first:

$ preview, wrong column
column 'name' not in ['company']
exit=1
$ run --yes
{"routine": "function:t_0tjqm9rUjfGY4MunGDM", "rows": 5, "per_row": 1, "observed_deltas": [1, 1, 1], "estimated_credits": 5, "balance": 391825, "enough_balance": true}
{"status": "complete", "total": 5, "finished": 5, "row_statuses": ["complete"], "estimated_credits": 5, "observed_delta": 5, "observed_delta_includes_other_activity": true, "run_requests_sent": 1}
exit=0

The balance-below-estimate branch was exercised with a scratch prices.json that set per_row to 1000000000, run from a temporary directory:

{"routine": "function:t_0tjqm9rUjfGY4MunGDM", "rows": 5, "per_row": 1000000000, "observed_deltas": [1000000000], "estimated_credits": 5000000000, "balance": 391820, "enough_balance": false}
{"refused": "balance 391820 is below the estimate 5000000000", "run_requests_sent": 0}
exit=2

The properties that define a pass are that the preview sends zero /run requests, that run without --yes exits 2 with zero requests, that run with --yes and a sufficient limit reports finished equal to total with every row complete, and that the exact request count is 1. The observed delta of 5 in the real run equaled the estimate of 5, which is an illustration from a window with no other activity and is not part of the pass condition.

How it works

A routine run charges credits when it executes, and the only account-level number the REST API exposes for credits is the balance. calibrate therefore measures a price from the outside: read the balance, run one row, wait for the balance to settle, read it again. Other consumers of the workspace can only add to that difference, so the smallest of several probes is the tightest upper bound the script can state. preview multiplies that figure by the number of non-empty cells in the chosen column.

The confirmation gate is evaluated before the first POST /run. The run_requests_sent counter lives in the HTTP helper, so the property "nothing was sent" is read from the process and does not have to be inferred from an unchanged balance, which other activity could mask or move.

Common issues

The figure is an observation of one input, not a price list

Calibration measures the rows given in --example. A routine whose cost depends on the input, or that charges only when it finds a result, can cost something else on a different row. The Company Employee Count routine measured 1 credit per row here (a probe with a delta of 1 on each of two calibrations), while the published routine price list in this documentation set recorded 6.2 for it on 2026-10-09 from the CLI. The CLI was not queried on 2026-10-20, so the two figures are from different dates and different methods and are not reconciled here.

A single probe can mislead

A calibration with --probes 1 that happens to overlap another job records the polluted delta as the price. The deltas of [6, 1] and [1, 6, 1] above show that it happens in a shared workspace. Use at least three probes for a routine that is going to be run in volume.

A busy workspace raises the calibrated price

The price is the smallest balance delta among the probes, so it is only as low as the quietest probe. An independent re-run of this document while other jobs were spending in the same workspace calibrated a per-row figure above 1, which made the estimate for five rows exceed a --max-credits 10 limit and the script refused with exit 2, as designed. Raise --probes or calibrate when the workspace is quiet, and read observed_deltas in prices.json to see whether the probes agreed.

The balance lags the run

The script waits 6 seconds before each balance read after a run. A shorter wait was not tested, so the floor is unknown.

A wrong column is an error, not an empty estimate

An earlier version of the script had no header check, and --column name against a file whose column is company produced 0 rows and an estimate of 0 credits, which reads as a free run. The script above exits with the list of real column names instead, as the wrong-column control in section 6 shows.

Not covered

Only routines with one required input were calibrated. The batch endpoints (run-batch) were not preflighted, and rows that return no result were not measured, so the script does not claim they are free.

Next steps


verification:
  status: verified
  tested_at: "2026-10-20"
  product_version: "public/v0"
  command: "python3 preflight.py calibrate <routine> --field <name> --example <value>; python3 preflight.py preview <routine> companies.csv --column company; python3 preflight.py run <routine> companies.csv --column company"
  expected_result: "Preview prints the estimate and reports run_requests_sent 0. run without --yes exits 2 with run_requests_sent 0. run with --yes within the limits completes with every row complete."

Related Articles