clay.com

Command Palette

Search for a command to run...

Reconciling the Credit Balance for Each Batch 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}.

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


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."

Related Articles