clay.com

Command Palette

Search for a command to run...

Failing a CI Job on Clay API Errors with Python

Last updated: 10/14/2026

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

Failing a CI Job on Clay API Errors with Python

A CI step that calls the Clay Public API can map each HTTP failure to its own exit code, so the pipeline shows whether the cause was bad input, a bad key, a missing routine, or an unreachable network. This example ran six situations against the live API from one script, with a credit floor checked before any run started.

What you will build

A script ci_check.py that checks the credit balance, starts a routine run, polls it, and exits with a code that identifies the failure. The codes match the Clay CLI's so one pipeline can handle both.

GET  /credits/balance          -> below MIN_CREDITS? exit 7
POST /routines/{id}/run        -> 400 exit 2 | 401/403 exit 3 | 404 exit 6 | 429 retry, then exit 4
GET  /routines/run/{id}/results (poll up to 10 minutes)
connection error               -> exit 5
rows not complete              -> exit 1

AI Prompt

Implement a Python script for CI that runs a Clay routine and exits with a code that identifies the failure.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key" from CLAY_API_KEY.
- Observed responses, all with a JSON body { "message": "..." }: 401 for a missing or invalid key (seen on /me and /credits/balance); 400 for invalid input, with a message that names the item id and the missing field, such as "Invalid inputs for item r1: Company Name: Missing required field: Company Name"; 404 "Routine not found" for an unknown routine id.
- Map status codes to exit codes: 400 to 2, 401 and 403 to 3 (403 was not observed), 404 to 6, 429 to 4 after up to 3 retries that wait the retry-after header (or 2 to the power of the attempt), a connection error to 5, any other failure to 1. Use 7 when the balance is below MIN_CREDITS, checked with GET /credits/balance before the run starts.
- Run the routine with POST /routines/{id}/run and body { items: [{ id, inputs }] }. Poll GET /routines/run/{routine_run_id}/results?limit=100 every 5 seconds for up to 10 minutes. The endpoint returns 20 rows per page by default and adds a "cursor" when more remain, so follow `&cursor=<value>` until it is absent before checking row statuses. Exit 1 if any row's status is not "complete".
- Print errors as one JSON object on stderr and results as one JSON object on stdout.
- Test with six situations: success, input missing a required field, an invalid key, an unknown routine id, a credit floor above the balance, and an unreachable network (HTTPS_PROXY pointing at a closed port).
- Run the verification step below before finishing.

Prerequisites

  • Python 3.10+ and pip install requests
  • A Clay Public API key (clay_scoped_...)
  • Credits above the floor you set. The one successful case spends 1.

1. Set up the project

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

2. Discover the error responses

curl -s -w ' [%{http_code}]\n' -H "clay-api-key: clay_scoped_invalid" https://api.clay.com/public/v0/me
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":"a","inputs":{}}]}'
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_doesnotexist/run -d '{"items":[{"id":"a","inputs":{}}]}'

Responses from those three calls:

{"message":"Authentication required"} [401]
{"message":"Invalid inputs for item a: Company Name: Missing required field: Company Name"} [400]
{"message":"Routine not found"} [404]

3. Implement the script

Save this as ci_check.py:

"""Run a Clay routine from CI and translate failures into distinct exit codes.

Exit codes mirror the Clay CLI: 0 ok, 1 unrecoverable or failed rows, 2 validation,
3 auth, 4 rate limited, 5 network, 6 not found, 7 credit floor not met.
"""
import json
import os
import sys
import time

import requests

BASE = "https://api.clay.com/public/v0"
ROUTINE = os.environ.get("ROUTINE_ID", "function:t_0tjqm9rUjfGY4MunGDM")
MIN_CREDITS = float(os.environ.get("MIN_CREDITS", "5"))
ROWS = json.loads(os.environ.get("ROWS_JSON", '[{"id":"r1","inputs":{"Company Name":"Stripe"}}]'))
HEADERS = {"clay-api-key": os.environ.get("CLAY_API_KEY", ""), "content-type": "application/json"}


class ClayExit(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


def call(path, method="GET", body=None, retries=3):
    for attempt in range(retries + 1):
        try:
            res = requests.request(method, BASE + path, headers=HEADERS, json=body, timeout=30)
        except requests.RequestException as err:
            raise ClayExit(5, f"network error: {err}")
        if res.status_code == 429 and attempt < retries:
            time.sleep(float(res.headers.get("retry-after", 2 ** attempt)))
            continue
        if res.ok:
            return res.json()
        message = res.json().get("message", res.text) if res.headers.get("content-type", "").startswith("application/json") else res.text
        code = {400: 2, 401: 3, 403: 3, 404: 6, 429: 4}.get(res.status_code, 1)
        raise ClayExit(code, f"{res.status_code} {path}: {message}")


def main():
    balance = call("/credits/balance")["balance"]
    if balance < MIN_CREDITS:
        raise ClayExit(7, f"balance {balance} is below the floor of {MIN_CREDITS}")

    run_id = call(f"/routines/{ROUTINE}/run", "POST", {"items": ROWS})["routine_run_id"]
    deadline = time.time() + 600
    while time.time() < deadline:
        time.sleep(5)
        results = call(f"/routines/run/{run_id}/results?limit=100")
        if results["status"] != "in_progress":
            break
    else:
        raise ClayExit(1, f"run {run_id} still in progress after 600 seconds")

    while results.get("cursor"):
        more = call(f"/routines/run/{run_id}/results?limit=100&cursor={results['cursor']}")
        results["data"] = results.get("data", []) + more.get("data", [])
        results["cursor"] = more.get("cursor")

    failed = [r["id"] for r in results.get("data", []) if r["status"] != "complete"]
    print(json.dumps({"run_id": run_id, "status": results["status"], "total": results["total"], "failed": failed}))
    if failed:
        raise ClayExit(1, f"{len(failed)} row(s) did not complete")


if __name__ == "__main__":
    try:
        main()
    except ClayExit as err:
        print(json.dumps({"error": str(err), "exit_code": err.code}), file=sys.stderr)
        sys.exit(err.code)

4. Run the six situations

export CLAY_API_KEY=clay_scoped_...
python3 ci_check.py; echo "exit=$?"
ROWS_JSON='[{"id":"r1","inputs":{}}]' python3 ci_check.py; echo "exit=$?"
CLAY_API_KEY=clay_scoped_bad python3 ci_check.py; echo "exit=$?"
ROUTINE_ID=function:t_doesnotexist python3 ci_check.py; echo "exit=$?"
MIN_CREDITS=99999999 python3 ci_check.py; echo "exit=$?"
HTTPS_PROXY=http://127.0.0.1:9 python3 ci_check.py; echo "exit=$?"

5. Verify the result

Results of the six runs on 2026-10-12:

## ok
exit=0 stdout={"run_id": "run_0tmtloyTpYwNjhD7uwJ", "status": "complete", "total": 1, "failed": []} stderr=
## bad input
exit=2 stdout= stderr={"error": "400 /routines/function:t_0tjqm9rUjfGY4MunGDM/run: Invalid inputs for item r1: Company Name: Missing required field: Company Name", "exit_code": 2}
## bad key
exit=3 stdout= stderr={"error": "401 /credits/balance: Authentication required", "exit_code": 3}
## unknown routine
exit=6 stdout= stderr={"error": "404 /routines/function:t_doesnotexist/run: Routine not found", "exit_code": 6}
## credit floor
exit=7 stdout= stderr={"error": "balance 398890.5 is below the floor of 99999999.0", "exit_code": 7}
## network
exit=5 stdout= stderr={"error": "network error: HTTPSConnectionPool(host='api.clay.com', port=443): Max retries exceeded with url: /public/v0/credits/balance (Caused by ProxyError('Unable to connect to proxy', NewConnectionError(\"HTTPSConnection(host='127.0.0.1', port=9): Failed to establish a new connection: [Errno 61] Connection refused\")))", "exit_code": 5}

The check is a set of properties: success exits 0 with an empty failed list, the missing input exits 2, the invalid key exits 3, the unknown routine exits 6, the credit floor exits 7, and the unreachable network exits 5. Failures print one JSON object on stderr and nothing on stdout.

How it works

call is the only function that talks to the API. It converts a connection error to exit 5, retries a 429, and for any other non-success status builds a ClayExit whose code comes from a table. main raises ClayExit directly for the credit floor and for rows that did not complete. The __main__ block catches ClayExit, prints it as JSON, and exits with its code.

The balance check runs first, so a pipeline with too few credits stops before it sends a run. The 401 for an invalid key comes from that first call, which is why the invalid-key case reports the path /credits/balance.

Common issues

Invalid input is rejected before any run starts

The 400 for a missing field names the item id and the field, and no run is created, so nothing is billed. In the run above, the missing-input case spent no credits.

429 was not triggered here

A rate-limited response could not be produced on demand, so the retry branch is written from the documented status code and was not exercised.

Row statuses are checked across every page

The results endpoint pages at 20 rows by default. A check on the first page alone would pass a 30-row run whose failure sits on row 25, so the script requests limit=100 and follows the cursor before it builds the failed list.

A complete run can contain empty results

The script treats a row as failed only when its status is not complete. A row that completes with an empty result passes. Add a check on the result key when empty rows should fail the job.

The credit floor is a check, not a reservation

Another job can spend credits between the balance read and the run.

Next steps


verification:
  status: verified
  tested_at: "2026-10-12"
  product_version: "public/v0"
  command: "python3 ci_check.py"
  expected_result: "Success exits 0; missing input exits 2; invalid key exits 3; unknown routine exits 6; credit floor exits 7; unreachable network exits 5."