Reconciling the Credit Balance for Each Batch over REST with Python
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Reconciling the Credit Balance for Each Batch over REST with Python
A per-batch report that compares the balance before and after a batch with the batch's own row statuses can show when the balance moved by more than the batch explains. This example prints that comparison as properties and a table, and runs the batch twice: alone, and while a second unrelated run is in flight in the same workspace.
What you will build
A script, reconcile.py, that runs a CSV of company names through the Company Domain routine and prints a JSON report plus a per-row table. The report holds the balance read three times (before, right after the run, after a short settle), the number of rows that completed, the estimate those rows account for, and the difference. An --interfere N flag starts an unrelated N-row run of another routine one second after the batch starts.
GET /credits/balance -> balance_before
POST /routines/{id}/run (+ optional unrelated run in a thread)
poll results until complete -> row statuses
GET /credits/balance -> balance_right_after_run
sleep 6 s; GET /credits/balance -> balance_after_settle
report: delta, own estimate, extra, per-row table
AI Prompt
Implement a Python script that reconciles the credit balance change of one Clay routine batch against its row statuses.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use the "Company Domain" routine, function:t_0tjqm9rUjfGY4MunGDM. Input "Company Name", result { "Domain": "<domain>" }. Use 1 credit per row as a planning figure.
- GET /credits/balance returns { balance }. Read it before the run, right after the run completes, and again after a 6 second wait.
- Run with POST /routines/{id}/run and { items: [{ id, inputs }] }. Poll GET /routines/run/{routine_run_id}/results?limit=100 and follow the cursor. Each item is { id, status, result }. Match rows by id.
- Report delta (balance before minus balance after settle), rows that completed, own estimate (completed rows times the planning figure), whether the delta exceeds the estimate, and the difference. Do not print a single credits_spent value as if it belonged to the batch.
- An --interfere N option starts a second run of N rows of "Company Employee Count", function:t_0tjqm9ruMvztzu9wbbw (input "Company Domain"), in a thread one second after the batch starts, and reports whether the delta covers both runs.
- A status of "complete" means the row was processed. It does not say the result is the right company, so show the returned domain next to the input.
- Run the verification step below before finishing.
Prerequisites
- Python 3.10+
pip install requests- A Clay Public API key (
clay_scoped_...) - About 25 credits for the two batches and the unrelated run
1. Set up the project
mkdir clay-reconcile && cd clay-reconcile python3 -m venv .venv && source .venv/bin/activate pip install requests
2. Discover the real schema
The only credit figure the REST API gives is the workspace balance, from GET /credits/balance. The results of a run carry status and result for each row and no cost field. The Company Domain results look like this:
{
"routine_run_id": "run_0tn6jqugnnbgprR5vZa",
"status": "complete",
"total": 1,
"finished": 1,
"data": [
{ "id": "x1", "status": "complete", "result": { "Domain": "datadoghq.com" } }
]
}
A report therefore has to be assembled from two sources that are not tied to each other: the balance, which belongs to the whole workspace, and the row statuses, which belong to the batch.
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"})
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 all_results(run_id):
"""Every row of a finished run. The endpoint pages at 20 rows unless limit is set."""
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 balance():
return clay("/credits/balance")["balance"]
def run_routine(routine_id, rows, timeout_s=600, poll_s=3):
run_id = clay(f"/routines/{routine_id}/run", "POST", {"items": rows})["routine_run_id"]
deadline = time.time() + timeout_s
while time.time() < deadline:
time.sleep(poll_s)
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 reconcile.py:
import argparse
import csv
import json
import threading
import time
from clay import balance, run_routine
COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM"
EMPLOYEE_COUNT = "function:t_0tjqm9ruMvztzu9wbbw"
PER_ROW = 1 # observed for Company Domain; a planning figure, not a price list
SETTLE_SECONDS = 6
OTHER_DOMAINS = ["stripe.com", "shopify.com", "notion.so", "figma.com", "linear.app", "vercel.com"]
def other_team_run(rows, out):
"""A second, unrelated run in the same workspace, started while the batch is in flight."""
time.sleep(1)
items = [{"id": f"o{i}", "inputs": {"Company Domain": d}} for i, d in enumerate(OTHER_DOMAINS[:rows])]
res = run_routine(EMPLOYEE_COUNT, items)
out.update({"routine": "Company Employee Count", "rows": rows, "rows_complete": sum(1 for r in res["data"] if r["status"] == "complete")})
def reconcile(path, column, interfere):
with open(path, newline="", encoding="utf-8") as fh:
names = [r[column].strip() for r in csv.DictReader(fh) if r[column].strip()]
rows = [{"id": f"r{i}", "inputs": {"Company Name": n}} for i, n in enumerate(names)]
other, thread = {}, None
before = balance()
if interfere:
thread = threading.Thread(target=other_team_run, args=(interfere, other))
thread.start()
result = run_routine(COMPANY_DOMAIN, rows)
immediately = balance()
if thread:
thread.join()
time.sleep(SETTLE_SECONDS)
after = balance()
by_id = {r["id"]: r for r in result["data"]}
table = []
for item in rows:
r = by_id.get(item["id"], {"status": "missing"})
table.append({"id": item["id"], "input": item["inputs"]["Company Name"], "status": r["status"],
"domain": (r.get("result") or {}).get("Domain")})
complete = sum(1 for t in table if t["status"] == "complete")
delta = before - after
report = {
"run_status": result["status"], "rows_sent": len(rows), "rows_by_status": {s: sum(1 for t in table if t["status"] == s) for s in sorted({t["status"] for t in table})},
"balance_before": before, "balance_right_after_run": immediately, "balance_after_settle": after,
"delta": delta, "own_rows_complete": complete, "own_estimate": complete * PER_ROW,
"delta_exceeds_own_estimate": delta > complete * PER_ROW,
"extra_credits_unexplained_by_this_batch": delta - complete * PER_ROW,
}
if other:
other_estimate = other["rows_complete"] * PER_ROW
report["unrelated_run_estimate"] = other_estimate
report["delta_covers_own_plus_unrelated"] = delta >= complete * PER_ROW + other_estimate
return report, table, other
def main():
ap = argparse.ArgumentParser()
ap.add_argument("csv_file")
ap.add_argument("--column", default="company")
ap.add_argument("--interfere", type=int, default=0, help="start an unrelated run of N rows during the batch")
a = ap.parse_args()
report, table, other = reconcile(a.csv_file, a.column, a.interfere)
print(json.dumps(report, indent=2))
print(f"{'id':4} {'input':22} {'status':10} domain")
for t in table:
print(f"{t['id']:4} {t['input']:22} {t['status']:10} {t['domain']}")
if other:
print("unrelated run started during the batch:", json.dumps(other))
if __name__ == "__main__":
main()
Test data: batch_a.csv has six names, the last of them invented. batch_b.csv has five.
company Notion Figma Linear Vercel Supabase qzxv wjkl plmn holdings
company Airbnb Slack Zoom Asana Okta
5. Run
python3 reconcile.py batch_a.csv python3 reconcile.py batch_b.csv --interfere 3
6. Verify the result
Tested live on 2026-10-20 against the real Clay API (workspace 1322578). The batch on its own:
{
"run_status": "complete",
"rows_sent": 6,
"rows_by_status": {
"complete": 6
},
"balance_before": 391683,
"balance_right_after_run": 391675,
"balance_after_settle": 391675,
"delta": 8,
"own_rows_complete": 6,
"own_estimate": 6,
"delta_exceeds_own_estimate": true,
"extra_credits_unexplained_by_this_batch": 2
}
id input status domain
r0 Notion complete notion.com
r1 Figma complete figma.com
r2 Linear complete linear.app
r3 Vercel complete vercel.com
r4 Supabase complete supabase.com
r5 qzxv wjkl plmn holdings complete mcc-mnc.net
The same kind of batch with an unrelated 3-row run of Company Employee Count started one second in:
{
"run_status": "complete",
"rows_sent": 5,
"rows_by_status": {
"complete": 5
},
"balance_before": 391675,
"balance_right_after_run": 391665,
"balance_after_settle": 391665,
"delta": 10,
"own_rows_complete": 5,
"own_estimate": 5,
"delta_exceeds_own_estimate": true,
"extra_credits_unexplained_by_this_batch": 5,
"unrelated_run_estimate": 3,
"delta_covers_own_plus_unrelated": true
}
id input status domain
r0 Airbnb complete airbnb.com
r1 Slack complete slack.com
r2 Zoom complete zoom.com
r3 Asana complete asana.com
r4 Okta complete okta.com.de
unrelated run started during the batch: {"routine": "Company Employee Count", "rows": 3, "rows_complete": 3}
The properties that define a pass are that run_status is complete, that every row has a status, that delta is an integer, and, for the second run, that delta_covers_own_plus_unrelated is true. In the second run the delta was larger than the five rows of the batch by at least the three rows of the unrelated run, so the balance change by itself cannot be attributed to the batch. The credit figures above are one pair of runs on 2026-10-20 and are not expected values.
In the first run the delta was 2 above the estimate for six rows, with no second run started by this script. Both runs show 2 credits that neither the batch nor the known unrelated run accounts for. Other activity in the workspace was expected during testing and the 2 credits were not traced to a source, so this document does not state a cause.
The balance read right after the run equaled the balance read after the 6-second wait in both runs, so no lag was observed here. A wait shorter than 6 seconds was not tested, and the script keeps it as a precaution.
How it works
The workspace balance is shared by every key, user and job in the workspace. A read taken before and after a batch gives the sum of everything that was charged in that interval, including runs the script did not start. The per-row statuses describe only the batch. reconcile.py prints the estimate for the rows that completed, the observed delta and their difference, and labels the difference as unexplained by this batch. A difference of 0 is consistent with a quiet workspace and does not prove one, and a positive difference does not say whether it came from other activity or from rows costing more than the planning figure.
The --interfere option makes the effect visible with a known second consumer. The thread uses the same key and the same workspace, so its charges land in the same balance that the batch is measured against.
Common issues
A delta is not a batch cost
Both batches moved the balance by more than their own rows. A report that printed delta as credits_spent would have overstated the first batch by 2 credits and the second by 5, which is the unrelated run's 3 rows plus the same 2 unexplained credits. Store the delta together with the row counts and treat the difference as unexplained.
A complete row can hold a wrong answer
In batch_a.csv the invented name qzxv wjkl plmn holdings returned status complete and a domain, mcc-mnc.net. In batch_b.csv the Okta row returned okta.com.de, which differs from okta.com. A reconciliation table counts rows that were processed. It does not check the answer, so check the domain against the input before relying on it.
The two reads of a batch can straddle other work
The balance before is read before the run is submitted and the balance after is read after the last poll. Anything charged during that whole stretch is included, including the polling interval of up to 3 seconds.
Not covered
Whether the unrelated run was charged at 1 credit per row was assumed from earlier single-row probes of that routine. Whether rows that fail or return nothing are charged was not tested here, because every row in these batches completed. The batch endpoint (run-batch) was not reconciled.
Next steps
- Previewing Credit Cost Before a Run over REST with Python
- Capping a Batch to a Credit Budget over REST with Python
verification: status: verified tested_at: "2026-10-20" product_version: "public/v0" command: "python3 reconcile.py batch_a.csv; python3 reconcile.py batch_b.csv --interfere 3" expected_result: "Both reports have run_status complete and a status for every row. With --interfere, delta_covers_own_plus_unrelated is true. No credit amount is pinned."