Capping a Batch to a Credit Budget over REST with Python
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Capping a Batch to a Credit Budget over REST with Python
A routine that bills per row can overrun a job that loops or a list that is longer than expected. This Python script puts a ceiling on a run before it starts: it turns a credit budget into a row count, trims the list to that count, and refuses to run at all when not even one row fits. A budget of 3 on a 5-row list should send 3 rows and name the 2 left behind, and a budget of 0 should send nothing.
What you will build
A script that trims one routine run to the rows a budget can pay for and exits with code 1 when the budget covers none.
PER_ROW_CREDITS, BUDGET_CREDITS (settings)
↓
GET /credits/balance
↓
rows to run = floor(min(budget, balance) / per_row)
↓
POST /routines/{id}/run (only those rows)
↓
GET /routines/run/{id}/results -> GET /credits/balance -> credits_spent
AI Prompt
Implement a Python 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. 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 before any request.
- Poll the run for up to 10 minutes. Read the balance again a few seconds after the run completes.
- Run the verification step below before finishing.
Prerequisites
- Python 3.10+ and
pip install requests - A Clay Public API key (
clay_scoped_...) - The per-row credit estimate of the routine, read with
clay routines get
1. Set up the project
mkdir clay-budget && cd clay-budget python3 -m venv .venv && source .venv/bin/activate pip install requests
2. Configure credentials
export CLAY_API_KEY=clay_scoped_...
3. Implement the script
Save this shared 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"})
def clay(path, method="GET", body=None):
res = session.request(method, f"{BASE}{path}", json=body, timeout=60)
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):
"""Fetch every row. The endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain."""
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(5)
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 budget_cap.py:
import json
import os
import sys
import time
from clay import balance, run_routine
COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM"
COMPANIES = ["Notion", "Figma", "Linear", "Vercel", "Supabase"]
def main():
per_row = float(os.environ.get("PER_ROW_CREDITS", "0"))
budget = float(os.environ.get("BUDGET_CREDITS", "-1"))
if not per_row > 0 or budget < 0:
raise SystemExit("Set PER_ROW_CREDITS (> 0) and BUDGET_CREDITS (>= 0)")
before = balance()
affordable = int(min(budget, before) // per_row)
to_run, skipped = COMPANIES[:affordable], COMPANIES[affordable:]
print(json.dumps({"balance": before, "budget": budget, "per_row": per_row, "rows": len(COMPANIES), "running": len(to_run), "skipped": skipped}))
if not to_run:
print("Nothing affordable within the budget; no run started", file=sys.stderr)
sys.exit(1)
results = run_routine(COMPANY_DOMAIN, [{"id": str(i), "inputs": {"Company Name": n}} for i, n in enumerate(to_run)])
time.sleep(5)
after = balance()
by_id = {r["id"]: r.get("result") for r in results.get("data", [])}
for i, name in enumerate(to_run):
print(json.dumps({"name": name, "result": by_id.get(str(i))}))
print(json.dumps({"status": results["status"], "credits_spent": before - after}))
if __name__ == "__main__":
try:
main()
except RuntimeError as err:
print(err, file=sys.stderr)
sys.exit(1)
4. Run
PER_ROW_CREDITS=1 BUDGET_CREDITS=0 python3 budget_cap.py; echo "exit=$?" PER_ROW_CREDITS=1 BUDGET_CREDITS=3 python3 budget_cap.py; echo "exit=$?"
5. Verify the result
Both invocations ran on 2026-10-12 against workspace 1322578:
Nothing affordable within the budget; no run started
{"balance": 398861.5, "budget": 0.0, "per_row": 1.0, "rows": 5, "running": 0, "skipped": ["Notion", "Figma", "Linear", "Vercel", "Supabase"]}
exit=1
{"balance": 398861.5, "budget": 3.0, "per_row": 1.0, "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", "credits_spent": 3.0}
exit=0
The check is a set of properties: with a budget of 0 no run starts and the exit code is 1, and with a budget of 3 exactly 3 of the 5 rows run, 2 are listed under skipped, and credits_spent is at most 3.
How it works
The row count is the smaller of the budget and the current balance, divided by the per-row estimate and rounded down. Everything past that count is left out of the request, so the cap is enforced before the first billable call. credits_spent comes from two balance reads, which makes it an observed figure and not a calculation.
Common issues
The cap is only as good as the per-row figure
An estimate that is too low lets a run spend past the budget. In this run Company Domain charged exactly its estimate of 1. Work Email did not behave that way in other examples in this batch, where the same two-row run cost 13 credits once and 6 another time, so measure a small run before relying on a figure.
The price has to come from the CLI
The REST API has no endpoint for a routine's price, so the script takes the per-row figure as a setting. Read it with clay routines get, which needs a signed-in CLI session.
Longer lists need paged results
The helper requests limit=100 and follows the cursor that the results endpoint adds beyond 20 rows. Without that, a budget large enough for 25 rows would still return only the first 20 to the caller.
A budget check does not reserve credits
Another job in the workspace can spend credits between the balance read and the run, so the check bounds this script's spend and not the workspace's.
Rows that return nothing can leave part of the budget unspent
The script reserves the full per-row estimate for every row, and the measured spend can come in lower.
Next steps
- Making a Budget-Capped Batch Call Over REST with TypeScript
- Resuming an Interrupted Batch from a State File over REST with Python
verification: status: verified tested_at: "2026-10-12" product_version: "public/v0" command: "PER_ROW_CREDITS=1 BUDGET_CREDITS=3 python3 budget_cap.py" expected_result: "3 of 5 rows run, 2 skipped, status complete, and credits_spent of 3; a budget of 0 starts no run and exits 1."