{
  "page": "docs/sdks",
  "title": "SDKs",
  "slug": "sdks",
  "description": "The TypeScript and Python clients: install, the agent client, the org client, errors and retries.",
  "section": "Getting started",
  "updated": "2026-09-29",
  "blocks": [
    {
      "type": "p",
      "text": "Two clients wrap the REST API: `@arettic/sdk` for TypeScript and `arettic` for Python. Both do the same job. They send your key, turn the API's error envelope into one exception, retry the calls that are safe to retry, and give every `execute` call an idempotency key so a retry can never charge twice. Everything they do you can also do with plain HTTP. The [quickstart](/docs/quickstart) shows the curl calls, and [MCP](/docs/mcp) is the route for agents that speak MCP."
    },
    {
      "type": "p",
      "text": "Both clients are thin. The TypeScript client types every request and response. The Python client returns each response as a plain `dict`, exactly the JSON the API sent. So what you read on the [execute](/docs/execute) and [receipts](/docs/receipts) pages is what comes back."
    },
    {
      "type": "h2",
      "id": "install",
      "text": "Install"
    },
    {
      "type": "code",
      "title": "npm",
      "lang": "bash",
      "code": "npm install @arettic/sdk"
    },
    {
      "type": "code",
      "title": "pip",
      "lang": "bash",
      "code": "pip install arettic"
    },
    {
      "type": "note",
      "text": "Pre-launch: `@arettic/sdk` and `arettic` are published to npm and PyPI at launch, so these commands do not find them yet. Until then keys go to design partners. Everyone else can [join the waitlist](/waitlist)."
    },
    {
      "type": "table",
      "caption": "What each package needs",
      "head": [
        "Package",
        "Runtime",
        "Dependencies",
        "Version"
      ],
      "rows": [
        [
          "@arettic/sdk",
          "Node 18 or newer, Deno, Bun or a browser: anything with a global `fetch`. ESM only.",
          "None.",
          "0.1.0"
        ],
        [
          "arettic",
          "Python 3.9 or newer.",
          "None. The standard library's `urllib` does the HTTP, so there is no `requests` or `httpx` to clash with yours. Typed (`py.typed`).",
          "0.1.0"
        ]
      ]
    },
    {
      "type": "p",
      "text": "Both clients read their settings from the environment when you pass nothing, so a key never has to live in code."
    },
    {
      "type": "table",
      "caption": "Environment variables the clients read",
      "head": [
        "Variable",
        "Read by",
        "What it does"
      ],
      "rows": [
        [
          "ARETTIC_API_KEY",
          "Both clients",
          "The key to send when you pass none. An agent key (`sk_test_…` or `sk_live_…`) for `Arettic`, an org key (`ok_…`) for `AretticOrg`."
        ],
        [
          "ARETTIC_API_URL",
          "Both clients",
          "The API's address when you pass no `baseUrl` (`base_url` in Python). Default `https://api.arettic.com`. Trailing slashes are dropped."
        ],
        [
          "ARETTIC_ORG_ID",
          "Python `AretticOrg` only",
          "The org id when you pass no `org_id`. The TypeScript org client always takes `orgId` in its options."
        ]
      ]
    },
    {
      "type": "h2",
      "id": "agent-client",
      "text": "The agent client"
    },
    {
      "type": "p",
      "text": "`Arettic` is the client for agent keys. Make one per process and reuse it. With no options it reads `ARETTIC_API_KEY` and `ARETTIC_API_URL`; the options below override them."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { Arettic } from \"@arettic/sdk\";\n\nconst arettic = new Arettic({\n  apiKey: process.env.ARETTIC_API_KEY,\n  baseUrl: \"https://api.arettic.com\",\n  maxRetries: 3, // the default\n  timeoutMs: 60_000, // per attempt; the default\n  userAgent: \"research-bot/1.0\", // goes in front of arettic-sdk-ts/0.1.0\n});"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "import os\n\nfrom arettic import Arettic\n\nclient = Arettic(\n    api_key=os.environ[\"ARETTIC_API_KEY\"],\n    base_url=\"https://api.arettic.com\",\n    max_retries=3,  # the default\n    timeout=60.0,  # seconds; the default\n)"
    },
    {
      "type": "table",
      "caption": "Constructor options",
      "head": [
        "TypeScript",
        "Python",
        "Default",
        "What it does"
      ],
      "rows": [
        [
          "apiKey",
          "api_key",
          "`ARETTIC_API_KEY`",
          "The key sent as `Authorization: Bearer`. TypeScript leaves it off public data calls; Python sends it on every call once it has one."
        ],
        [
          "baseUrl",
          "base_url",
          "`ARETTIC_API_URL`, then `https://api.arettic.com`",
          "Where requests go. Give the API's own address, with no path."
        ],
        [
          "maxRetries",
          "max_retries",
          "3",
          "How many times a safe request is sent again after a retryable error. 0 sends it once. See [retries](/docs/sdks#retries)."
        ],
        [
          "timeoutMs",
          "timeout",
          "60 000 ms in TypeScript, 60.0 s in Python",
          "TypeScript: the limit for one attempt, from connect to the last byte of the body. Python: the limit for connecting and for each wait for data."
        ],
        [
          "fetch",
          "(none)",
          "the global `fetch`",
          "TypeScript only. A `fetch` to use instead of the global one, for a runtime without one or for tests. With neither, the constructor throws."
        ],
        [
          "userAgent",
          "(none)",
          "(none)",
          "TypeScript only. Text put before the SDK's own `arettic-sdk-ts/0.1.0` in the `User-Agent` header. Python always sends `arettic-sdk-python/0.1.0`."
        ]
      ]
    },
    {
      "type": "p",
      "text": "Every TypeScript method takes an options object as its last argument: `{ signal, timeoutMs, maxRetries }`. `signal` is an `AbortSignal`; aborting rejects the call with the code `aborted`. `execute` also takes `idempotencyKey`. The Python client has no per-call options; set them on the client."
    },
    {
      "type": "h3",
      "id": "public-data",
      "text": "Public data, no key needed"
    },
    {
      "type": "table",
      "caption": "Public data methods",
      "head": [
        "TypeScript",
        "Python",
        "Calls"
      ],
      "rows": [
        [
          "taskTypes()",
          "task_types()",
          "`GET /v1/task-types`: the task types, each with its pass rule, input and output."
        ],
        [
          "tools({ taskType, region, mode })",
          "tools(task_type=, region=, mode=)",
          "`GET /v1/tools`: the catalog with scores. `mode: \"test\"` lists the mock tools test keys buy from."
        ],
        [
          "tool(id)",
          "tool(id)",
          "`GET /v1/tools/{id}`: one tool with scores by region, score history, latest benchmark and prices by plan."
        ],
        [
          "scores({ taskType, region, mode })",
          "scores(task_type=, region=, mode=)",
          "`GET /v1/scores`: the latest scores with every input."
        ],
        [
          "pricing()",
          "pricing()",
          "`GET /v1/pricing`: plans, credit rules and per-tool prices."
        ],
        [
          "formula()",
          "formula()",
          "`GET /v1/formula`: the score formula, its rules and the pass rules."
        ],
        [
          "reports()",
          "reports()",
          "`GET /v1/reports`: benchmark reports, published ones with results and upcoming ones with their date."
        ],
        [
          "report(slug)",
          "report(slug)",
          "`GET /v1/reports/{slug}`: one report with ranked results, method and sample cases."
        ],
        [
          "status()",
          "status()",
          "`GET /v1/status`: overall status, each component and incidents in the last 90 days."
        ]
      ]
    },
    {
      "type": "p",
      "text": "These work on a client made with no key at all. The TypeScript client sends no `Authorization` header on them even when it has a key; the Python client sends its key on every call, which the public endpoints ignore. The [public data](/docs/public-data) page says what each returns and under what licence."
    },
    {
      "type": "h3",
      "id": "recommend-and-execute",
      "text": "Recommend, execute and everything after"
    },
    {
      "type": "table",
      "caption": "Agent methods",
      "head": [
        "TypeScript",
        "Python",
        "Calls"
      ],
      "rows": [
        [
          "recommend(body)",
          "recommend(body) or recommend(**fields)",
          "`POST /v1/recommend`. Body: `task_type` or a plain-language `task`, `region`, `constraints`, `sort`, `limit`. Never retried. See [recommend](/docs/recommend)."
        ],
        [
          "execute(body, options)",
          "execute(body, idempotency_key=None, **fields)",
          "`POST /v1/execute`. Body: `tool_id`, `input` or `inputs`, `max_price`, `approval_id`, `fallback`, `idempotency_key`. Retried, because it always carries an idempotency key. See [execute](/docs/execute)."
        ],
        [
          "job(id)",
          "job(id)",
          "`GET /v1/jobs/{id}`: an async job's status and progress, and its per-item outcomes once it has finished."
        ],
        [
          "waitForJob(id, { intervalMs, timeoutMs, signal })",
          "wait_for_job(id, interval=2.0, timeout=600.0)",
          "Polls `job(id)` until the job is `done`, `failed` or `expired`. See [waiting](/docs/sdks#waiting)."
        ],
        [
          "receipts({ from, to, agentId, toolId, before, limit })",
          "receipts(from_=, to=, agent_id=, tool_id=, before=, limit=)",
          "`GET /v1/receipts`: the org's receipts, newest first. For the next page, pass the previous page's `next` as `before`."
        ],
        [
          "receipt(id)",
          "receipt(id)",
          "`GET /v1/receipts/{id}`: one receipt with every item's provider, cost, hashes, check version, outcome and refund."
        ],
        [
          "openDispute(body)",
          "open_dispute(body) or open_dispute(**fields)",
          "`POST /v1/disputes`. Body: `receipt_id` and `item_index`, or `execution_id`; `reason`; `evidence`. Never retried. See [disputes](/docs/disputes)."
        ],
        [
          "dispute(id)",
          "dispute(id)",
          "`GET /v1/disputes/{id}`: status, decision and refund."
        ],
        [
          "approval(id)",
          "approval(id)",
          "`GET /v1/approvals/{id}`: an approval request's status, for the agent that asked."
        ],
        [
          "waitForApproval(id, { intervalMs, timeoutMs, signal })",
          "(no helper; see [waiting](/docs/sdks#waiting))",
          "Polls `approval(id)` until an owner decides it or it expires."
        ],
        [
          "balance()",
          "balance()",
          "`GET /v1/balance`: the org's paid, trial and total credits, and the agent's month-to-date spend, budget and what is left of it."
        ],
        [
          "me()",
          "me()",
          "`GET /v1/agent`: the calling agent and its limits. TypeScript returns the `agent` object itself; Python returns the body, `{\"agent\": {...}}`."
        ]
      ]
    },
    {
      "type": "p",
      "text": "Python's `recommend`, `execute` and `open_dispute` take the body as a dict, as keyword arguments, or both; keywords win. `execute` never changes the dict you pass in. In TypeScript every request and response shape is exported as a type: `import type { ExecuteResult, Receipt, Job } from \"@arettic/sdk\"`."
    },
    {
      "type": "h3",
      "id": "execute-answers",
      "text": "Reading the answer from execute"
    },
    {
      "type": "p",
      "text": "`execute` answers in one of six shapes. Read `status` first. Money is always `{ credits, usd }`: credits as a whole number in a string (1 credit is $0.001) and the same amount in dollars with three decimals."
    },
    {
      "type": "table",
      "caption": "The six execute answers",
      "head": [
        "status",
        "When",
        "What else is in the answer"
      ],
      "rows": [
        [
          "passed",
          "One `input`, and the result passed its check",
          "`result`, `charged`, `refunded`, `execution_id`, `check_version`, and `receipt_id` with a live key."
        ],
        [
          "partial",
          "One `input`, and part of the result passed (web search)",
          "`result`, `reason`, and a pro-rata `charged`."
        ],
        [
          "failed",
          "One `input`, and the check failed or the provider errored",
          "`reason` only. No `result`, and nothing charged."
        ],
        [
          "completed",
          "`inputs` with up to 25 items, all run",
          "`summary` (`items`, `passed`, `partial`, `failed`) and `items[]`, each with its own `index`, `status`, `charged`, and `result` or `reason`."
        ],
        [
          "approval_required",
          "The purchase is over the agent's approval threshold or its monthly budget (HTTP 202)",
          "`approval_id`, `reason` (`over_threshold` or `over_budget`), `amount`, `expires_at`, `message`. Wait for the decision, then send the same request again with `approval_id`."
        ],
        [
          "queued",
          "`inputs` with more than 25 items, up to 1,000, with a live key (HTTP 202)",
          "`job_id`, `items`, `max_charge`, `poll`, `message`. Poll the job."
        ]
      ]
    },
    {
      "type": "p",
      "text": "A test key adds `test_mode: true` and `would_have_charged`, charges nothing and writes no receipt. It runs a batch of up to 1,000 inputs inline, so it never answers `queued`, and it never needs an approval. The reason codes and the `fallback` fields are on the [execute](/docs/execute) page."
    },
    {
      "type": "code",
      "title": "Response (example): test key, one input",
      "lang": "json",
      "code": "{\n  \"test_mode\": true,\n  \"tool_id\": \"mock-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"status\": \"passed\",\n  \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" },\n  \"would_have_charged\": { \"credits\": \"6\", \"usd\": \"0.006\" }\n}"
    },
    {
      "type": "code",
      "title": "Response (example): live key, one input",
      "lang": "json",
      "code": "{\n  \"status\": \"passed\",\n  \"execution_id\": \"6b1d3f0e-2c4a-4f8e-9a7b-1c2d3e4f5a6b\",\n  \"receipt_id\": \"0f7a9c2d-5e6b-4a1c-8d9e-2f3a4b5c6d7e\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"refunded\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" }\n}"
    },
    {
      "type": "code",
      "title": "Response (example): approval needed, HTTP 202",
      "lang": "json",
      "code": "{\n  \"status\": \"approval_required\",\n  \"approval_id\": \"a1c2e3f4-5b6a-4d7c-8e9f-0a1b2c3d4e5f\",\n  \"reason\": \"over_threshold\",\n  \"amount\": { \"credits\": \"25000\", \"usd\": \"25.000\" },\n  \"expires_at\": \"2026-09-30T09:12:45.000Z\",\n  \"message\": \"This is above the agent's approval threshold. An owner has been asked to approve it; retry with approval_id once approved.\"\n}"
    },
    {
      "type": "code",
      "title": "Response (example): queued as a job, HTTP 202",
      "lang": "json",
      "code": "{\n  \"status\": \"queued\",\n  \"job_id\": \"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\",\n  \"items\": 100,\n  \"max_charge\": { \"credits\": \"700\", \"usd\": \"0.700\" },\n  \"poll\": \"/v1/jobs/9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\",\n  \"message\": \"Running 100 items as a job. Poll the job for progress; each item is charged only if it passes.\"\n}"
    },
    {
      "type": "h2",
      "id": "waiting",
      "text": "Waiting for jobs and approvals"
    },
    {
      "type": "p",
      "text": "A job answers `queued` at once and runs in the background. An approval waits for a person. The helpers poll for you and return the final object."
    },
    {
      "type": "table",
      "caption": "Polling helpers",
      "head": [
        "Helper",
        "Polls",
        "Returns when",
        "Defaults"
      ],
      "rows": [
        [
          "waitForJob(id, { intervalMs, timeoutMs, signal })",
          "`GET /v1/jobs/{id}`",
          "`status` is `done`, `failed` or `expired`",
          "every 2 s, for up to 10 minutes"
        ],
        [
          "wait_for_job(id, interval=2.0, timeout=600.0)",
          "`GET /v1/jobs/{id}`",
          "the same",
          "every 2.0 s, for up to 600.0 s"
        ],
        [
          "waitForApproval(id, { intervalMs, timeoutMs, signal })",
          "`GET /v1/approvals/{id}`",
          "`status` is no longer `pending`: `approved`, `rejected`, `expired` or `used`",
          "every 5 s, for up to 24 hours"
        ]
      ]
    },
    {
      "type": "p",
      "text": "When the deadline passes, both SDKs raise `AretticApiError` with the code `timeout`, `retryable` true and the last polled object in `body`. Nothing is cancelled: the job keeps running and the approval stays open, so you can call the helper again later. In TypeScript, pass `signal` to stop waiting early; the call then rejects with the code `aborted`. Python has no approval helper; a short loop over `approval(id)` does the same."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "const inputs = [\n  { first_name: \"Emily\", last_name: \"Carter\", domain: \"acme.com\" },\n  { first_name: \"Jason\", last_name: \"Miller\", domain: \"example.com\" },\n  // ... up to 1,000\n];\nlet bought = await arettic.execute({ tool_id: toolId, inputs });\n\nif (bought.status === \"approval_required\") {\n  const approval = await arettic.waitForApproval(bought.approval_id, { intervalMs: 10_000 });\n  if (approval.status !== \"approved\") throw new Error(\"Approval \" + approval.status);\n  // The same tool and inputs, or the API answers approval_mismatch.\n  bought = await arettic.execute({ tool_id: toolId, inputs, approval_id: approval.approval_id });\n}\n\nif (bought.status === \"queued\") {\n  const job = await arettic.waitForJob(bought.job_id, { timeoutMs: 30 * 60_000 });\n  console.log(job.status, job.summary, job.receipt_id);\n}"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "import time\n\ninputs = [\n    {\"first_name\": \"Emily\", \"last_name\": \"Carter\", \"domain\": \"acme.com\"},\n    {\"first_name\": \"Jason\", \"last_name\": \"Miller\", \"domain\": \"example.com\"},\n    # ... up to 1,000\n]\nbought = client.execute(tool_id=tool_id, inputs=inputs)\n\nif bought[\"status\"] == \"approval_required\":\n    approval = client.approval(bought[\"approval_id\"])\n    while approval[\"status\"] == \"pending\":\n        time.sleep(10)\n        approval = client.approval(bought[\"approval_id\"])\n    if approval[\"status\"] != \"approved\":\n        raise RuntimeError(\"Approval \" + approval[\"status\"])\n    # The same tool and inputs, or the API answers approval_mismatch.\n    bought = client.execute(tool_id=tool_id, inputs=inputs, approval_id=approval[\"approval_id\"])\n\nif bought[\"status\"] == \"queued\":\n    job = client.wait_for_job(bought[\"job_id\"], interval=5.0, timeout=1800.0)\n    print(job[\"status\"], job.get(\"summary\"), job.get(\"receipt_id\"))"
    },
    {
      "type": "code",
      "title": "Response (example): a job while it runs",
      "lang": "json",
      "code": "{\n  \"job_id\": \"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\",\n  \"status\": \"running\",\n  \"items\": 100,\n  \"progress\": 37,\n  \"created_at\": \"2026-09-29T09:12:45.000Z\",\n  \"started_at\": \"2026-09-29T09:12:47.000Z\",\n  \"finished_at\": null\n}"
    },
    {
      "type": "p",
      "text": "While the job runs, `items` is the number of inputs and `progress` the number settled so far. Once the job has its receipt the same object also carries `receipt_id`, `tool_id`, `task_type`, `check_version`, `charged`, `refunded` and `summary`, and `items` becomes the per-item array (`index`, `status`, `charged`, and `result` or `reason`), the same shape as a `completed` answer. The [jobs](/docs/jobs) page has a full example."
    },
    {
      "type": "h2",
      "id": "org-client",
      "text": "The org client"
    },
    {
      "type": "p",
      "text": "`AretticOrg` is the client for org keys (`ok_…`). An org key does what its maker can do in the dashboard, for that one org, and only under `/v1/orgs/{orgId}/…`. A read key can call the reads and gets `forbidden` on anything else. Making and revoking org keys needs a signed-in owner, so the SDK has no call for it. The [org API](/docs/org-api) page lists every endpoint next to its dashboard action."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { AretticOrg } from \"@arettic/sdk\";\n\nconst org = new AretticOrg({\n  apiKey: process.env.ARETTIC_ORG_KEY, // with none, ARETTIC_API_KEY\n  orgId: \"your-org-id\", // required: the constructor throws without it\n  baseUrl: \"https://api.arettic.com\",\n  // maxRetries, timeoutMs, fetch and userAgent work as on Arettic\n});"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "import os\n\nfrom arettic import AretticOrg\n\n# Positional: key, then org id. With neither, ARETTIC_API_KEY and ARETTIC_ORG_ID are read;\n# without an org id the constructor raises ValueError.\norg = AretticOrg(os.environ[\"ARETTIC_ORG_KEY\"], \"your-org-id\", base_url=\"https://api.arettic.com\")"
    },
    {
      "type": "table",
      "caption": "Org client namespaces",
      "head": [
        "Namespace",
        "TypeScript",
        "Python",
        "Endpoints under /v1/orgs/{orgId}"
      ],
      "rows": [
        [
          "dashboard",
          "dashboard({ days })",
          "dashboard(days=None)",
          "`GET /dashboard`: balance, spend by day, agent and tool, items charged and not charged, budgets and pending items."
        ],
        [
          "balance",
          "balance()",
          "balance()",
          "`GET /balance`: paid and trial credits."
        ],
        [
          "plan",
          "plan()",
          "plan()",
          "`GET /plan`: the plan, its limits, today's usage and subscriptions."
        ],
        [
          "events",
          "events({ limit })",
          "events(limit=None)",
          "`GET /events`: recent events."
        ],
        [
          "agents",
          "list(), create(body), update(agentId, body), rotateKey(agentId), revokeKey(agentId)",
          "list(), create(**fields), update(agent_id, **fields), rotate_key(agent_id), revoke_key(agent_id)",
          "`GET /agents`, `POST /agents`, `PATCH /agents/{id}`, `POST /agents/{id}/rotate-key`, `POST /agents/{id}/revoke-key`. `create` takes `name`, `mode`, `monthly_budget_credits`, `approval_threshold_credits`; `update` also `ip_allowlist` and `status`. `create` and `rotateKey` return the key once."
        ],
        [
          "approvals",
          "list({ status }), decide(id, { decision, note })",
          "list(status=None), decide(id, decision, note=None)",
          "`GET /approvals` (pending first), `POST /approvals/{id}/decision` with `decision` `approve` or `reject`."
        ],
        [
          "receipts",
          "list(query), get(id), exportCsv({ from, to })",
          "list(**query), get(id), export_csv(from_=None, to=None)",
          "`GET /receipts` (same filters as the agent's `receipts`), `GET /receipts/{id}`, `GET /exports/receipts.csv`. The export returns CSV text, one row per item attempt; dates are `YYYY-MM-DD`."
        ],
        [
          "disputes",
          "list({ status }), create(body)",
          "list(status=None), create(**fields)",
          "`GET /disputes` (open first), `POST /disputes` with the same body as the agent's `openDispute`."
        ],
        [
          "invoices",
          "list(), get(id)",
          "list(), get(id, format=None)",
          "`GET /invoices`, `GET /invoices/{id}`. In Python, `format=\"html\"` returns the printable invoice as text."
        ],
        [
          "topups",
          "list(), create(body)",
          "list(), create(**fields)",
          "`GET /topups`, `POST /topups` with `amount_usd`, `currency`, `save_card`. The answer has a `checkout_url` for a person to pay at."
        ],
        [
          "autoReload / auto_reload",
          "get(), set(body)",
          "get(), set(**fields)",
          "`GET /auto-reload`, `PUT /auto-reload` with `enabled`, `threshold_usd`, `amount_usd`. Turning it on needs a saved card."
        ],
        [
          "notifications",
          "set({ low_balance_usd })",
          "set(low_balance_usd)",
          "`PUT /notifications`: the balance under which owners get `balance.low`."
        ],
        [
          "subscriptions",
          "start(body), cancel(plan)",
          "start(**fields), cancel(plan)",
          "`POST /subscriptions` with `plan`, `interval`, `founding`; `DELETE /subscriptions/{plan}`."
        ],
        [
          "billing",
          "update(body)",
          "update(**fields)",
          "`PATCH /billing`: `tax_id`, `country`, `billing_name`, `billing_address`."
        ],
        [
          "webhooks",
          "list(), create({ url, events }), test(id), delete(id), deliveries()",
          "list(), create(url, events=None), test(id), delete(id), deliveries()",
          "`GET /webhooks`, `POST /webhooks`, `POST /webhooks/{id}/test`, `DELETE /webhooks/{id}`, `GET /webhook-deliveries`. No `events` means every event. The signing secret is in the `create` answer, once. See [webhooks](/docs/webhooks)."
        ],
        [
          "members",
          "list(), invite({ email, role }), revokeInvite(inviteId), setRole(userId, role), remove(userId)",
          "list(), invite(email, role=None), revoke_invite(invite_id), set_role(user_id, role), remove(user_id)",
          "`GET /members`, `POST /invites`, `DELETE /invites/{id}`, `PATCH /members/{userId}`, `DELETE /members/{userId}`. Roles are `owner` and `member`."
        ],
        [
          "deletionRequests / deletion_requests",
          "list()",
          "list()",
          "`GET /deletion-requests`. Asking for a deletion needs a signed-in owner, not a key."
        ],
        [
          "testSets / test_sets",
          "list(), create(body), addCases(id, casesJsonl), delete(id)",
          "list(), create(**fields), add_cases(id, cases_jsonl), delete(id)",
          "`GET /test-sets`, `POST /test-sets` with `name`, `task_type`, `region`, `cases_jsonl`; `POST /test-sets/{id}/cases`; `DELETE /test-sets/{id}`. Private benchmarks, on the Pro plan."
        ],
        [
          "benchmarks",
          "list({ testSetId, limit }), run({ test_set_id, tools }), get(id)",
          "list(test_set_id=None, limit=None), run(test_set_id, tools), get(id)",
          "`GET /benchmarks`, `POST /benchmarks` (one run per tool), `GET /benchmarks/{id}`."
        ],
        [
          "provider",
          "get(), claim({ provider, evidence }), requestRetest(toolId)",
          "get(), claim(provider, evidence), request_retest(tool_id)",
          "`GET /provider`, `POST /provider/claims`, `POST /provider/retests`."
        ]
      ]
    },
    {
      "type": "p",
      "text": "TypeScript unwraps the list envelopes: `agents.list()` returns `Agent[]`, `approvals.list()` returns `Approval[]`, and `disputes.list()`, `invoices.list()`, `topups.list()`, `benchmarks.list()`, `webhooks.deliveries()` and `events()` return their arrays. `agents.update`, `approvals.decide` and `invoices.get` return the object itself. Python returns every body unchanged: `org.agents.list()[\"agents\"]`. `receipts.list` returns `{ receipts, next }` in both; pass `next` back as `before` for the next page, until it is `null`."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "// A test agent. Its key is in the answer once; store it now.\nconst { agent, key } = await org.agents.create({ name: \"research bot\", mode: \"test\" });\nconsole.log(key); // sk_test_…\n\n// A $50 monthly budget, and ask an owner before any single purchase over $20.\nawait org.agents.update(agent.id, {\n  monthly_budget_credits: 50_000,\n  approval_threshold_credits: 20_000,\n});\n\n// Approve what is waiting.\nfor (const a of await org.approvals.list({ status: \"pending\" })) {\n  await org.approvals.decide(a.approval_id, { decision: \"approve\", note: \"ok\" });\n}\n\n// September's receipts, page by page, then the same month as CSV.\nconst month = { from: \"2026-09-01\", to: \"2026-09-30\" };\nlet page = await org.receipts.list({ ...month, limit: 50 });\nconst receipts = [...page.receipts];\nwhile (page.next) {\n  page = await org.receipts.list({ ...month, before: page.next });\n  receipts.push(...page.receipts);\n}\nconst csv = await org.receipts.exportCsv(month);\n\n// A webhook for finished jobs and decided disputes. The secret is shown once.\nconst { webhook } = await org.webhooks.create({\n  url: \"https://agent.example.com/arettic\",\n  events: [\"job.completed\", \"dispute.decided\"],\n});\nconsole.log(webhook.secret);"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "# A test agent. Its key is in the answer once; store it now.\nmade = org.agents.create(name=\"research bot\", mode=\"test\")\nprint(made[\"key\"])  # sk_test_…\n\n# A $50 monthly budget, and ask an owner before any single purchase over $20.\norg.agents.update(made[\"agent\"][\"id\"], monthly_budget_credits=50_000, approval_threshold_credits=20_000)\n\n# Approve what is waiting.\nfor a in org.approvals.list(status=\"pending\")[\"approvals\"]:\n    org.approvals.decide(a[\"approval_id\"], \"approve\", note=\"ok\")\n\n# September's receipts, page by page, then the same month as CSV.\nmonth = {\"from_\": \"2026-09-01\", \"to\": \"2026-09-30\"}\npage = org.receipts.list(limit=50, **month)\nreceipts = list(page[\"receipts\"])\nwhile page[\"next\"]:\n    page = org.receipts.list(before=page[\"next\"], **month)\n    receipts.extend(page[\"receipts\"])\ncsv_text = org.receipts.export_csv(from_=\"2026-09-01\", to=\"2026-09-30\")\n\n# A webhook for finished jobs and decided disputes. The secret is shown once.\nhook = org.webhooks.create(\"https://agent.example.com/arettic\", events=[\"job.completed\", \"dispute.decided\"])\nprint(hook[\"webhook\"][\"secret\"])"
    },
    {
      "type": "h2",
      "id": "on-the-wire",
      "text": "What the SDK sends"
    },
    {
      "type": "p",
      "text": "If you would rather call the API yourself, or write a client for another language, this is the whole contract. A request is JSON over HTTPS with these headers; ids in paths are URL-encoded, and query parameters that are `undefined`, `null` or empty (`None` in Python) are left out."
    },
    {
      "type": "table",
      "caption": "Request headers",
      "head": [
        "Header",
        "Value",
        "Sent on"
      ],
      "rows": [
        [
          "Authorization",
          "`Bearer <key>`",
          "Every keyed call. TypeScript omits it on public data calls; Python sends it whenever the client has a key."
        ],
        [
          "Content-Type",
          "`application/json`",
          "Every call with a body."
        ],
        [
          "Accept",
          "`application/json`; TypeScript sends `text/csv, text/plain, */*` for the CSV export",
          "Every call."
        ],
        [
          "User-Agent",
          "`arettic-sdk-ts/0.1.0`, after your `userAgent` if you set one; or `arettic-sdk-python/0.1.0`",
          "Every call."
        ]
      ]
    },
    {
      "type": "code",
      "title": "curl: the request the SDKs make for execute",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/execute \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -H \"User-Agent: arettic-sdk-ts/0.1.0\" \\\n  -d '{\n    \"tool_id\": \"mock-verify-email\",\n    \"input\": { \"email\": \"jason@acme.com\" },\n    \"idempotency_key\": \"b3b0d3a2-6d5e-4f1a-9c2b-7e8f9a0b1c2d\"\n  }'"
    },
    {
      "type": "h2",
      "id": "errors",
      "text": "Errors"
    },
    {
      "type": "p",
      "text": "Everything that fails raises one class, `AretticApiError`. It carries the fields of the API's error envelope, `{ \"error\": { \"code\", \"message\", \"doc_url\", \"retryable\" } }`, plus what the SDK knows about the exchange. Every code is explained on the [error codes](/docs/errors) page, and `docUrl` points at the right entry."
    },
    {
      "type": "code",
      "title": "Response (example): HTTP 402",
      "lang": "json",
      "code": "{\n  \"status\": \"declined\",\n  \"error\": {\n    \"code\": \"insufficient_credits\",\n    \"message\": \"This needs 7 credits ($0.007); the org has 0 ($0.000). Top up to continue.\",\n    \"doc_url\": \"https://arettic.com/docs/errors#insufficient_credits\",\n    \"retryable\": false\n  }\n}"
    },
    {
      "type": "table",
      "caption": "AretticApiError fields",
      "head": [
        "TypeScript",
        "Python",
        "Holds"
      ],
      "rows": [
        [
          "status",
          "status",
          "The HTTP status, or 0 when no response arrived."
        ],
        [
          "code",
          "code",
          "The error code, for example `insufficient_credits`. Link to it as [/docs/errors#insufficient_credits](/docs/errors#insufficient_credits)."
        ],
        [
          "message",
          "message",
          "The API's own sentence about what went wrong and what to do. Python's `str(err)` is `message (HTTP 402, insufficient_credits)`."
        ],
        [
          "docUrl",
          "doc_url",
          "The docs entry for the code."
        ],
        [
          "retryable",
          "retryable",
          "Whether sending the same request again later can succeed. The API sets it per code, and the SDK retries on it (below)."
        ],
        [
          "retryAfter",
          "retry_after",
          "Seconds to wait, from the `Retry-After` header when the API sent one (rate limits). Read as a number of seconds or as an HTTP date."
        ],
        [
          "body",
          "body",
          "The whole parsed response. Some errors add fields next to `error`, such as `status: \"declined\"` when a purchase was refused."
        ],
        [
          "requestId",
          "(none)",
          "TypeScript only: the `x-request-id` header, for support."
        ],
        [
          "cause",
          "__cause__",
          "The underlying error behind a network failure or timeout."
        ],
        [
          "name",
          "(none)",
          "Always `AretticApiError`, for logs that print `err.name`."
        ]
      ]
    },
    {
      "type": "p",
      "text": "A few codes come from the SDK itself, because there was no response to read them from. They resolve on the [error codes](/docs/errors) page too."
    },
    {
      "type": "table",
      "caption": "Codes the SDKs raise without an API envelope",
      "head": [
        "code",
        "status",
        "retryable",
        "When"
      ],
      "rows": [
        [
          "network_error",
          "0",
          "yes",
          "No response at all: DNS failed, the connection was refused or reset, or the reply was not HTTP."
        ],
        [
          "timeout",
          "0",
          "yes",
          "No response within `timeoutMs` (`timeout` in Python). Also what `waitForJob`, `wait_for_job` and `waitForApproval` raise when their deadline passes; then `body` is the last polled object."
        ],
        [
          "aborted",
          "0",
          "no",
          "TypeScript only: your `AbortSignal` fired."
        ],
        [
          "unauthenticated",
          "0",
          "no",
          "TypeScript only: a keyed method was called on a client with no key, so nothing was sent. Python sends the request without a key and the API answers 401 with the same code."
        ],
        [
          "unexpected_redirect",
          "the redirect's own status",
          "no",
          "Python only: the server redirected, and the client refuses to follow because following would resend your key elsewhere. Point `base_url` at the API itself."
        ],
        [
          "rate_limited, internal_error, http_error",
          "the response's status",
          "yes for 429 and 5xx",
          "The response was not the API's envelope, so a proxy or load balancer answered. TypeScript uses `rate_limited` for 429, `internal_error` for 5xx and `http_error` otherwise; Python uses `rate_limited` for 429 and `http_error` otherwise."
        ]
      ]
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { AretticApiError } from \"@arettic/sdk\";\n\ntry {\n  await arettic.execute({\n    tool_id: \"mock-find-email\",\n    input: { first_name: \"Emily\", last_name: \"Carter\", domain: \"acme.com\" },\n  });\n} catch (err) {\n  if (!(err instanceof AretticApiError)) throw err;\n  console.error(err.status, err.code, err.message, err.docUrl);\n  if (err.code === \"insufficient_credits\") {\n    // 402, not retryable: top up, or lower max_price.\n  } else if (err.retryable) {\n    // A read or an execute was already retried. A plain POST like recommend was sent once:\n    // wait err.retryAfter seconds (or a moment) and send it again yourself.\n  }\n}"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "from arettic import AretticApiError\n\ntry:\n    client.execute(\n        tool_id=\"mock-find-email\",\n        input={\"first_name\": \"Emily\", \"last_name\": \"Carter\", \"domain\": \"acme.com\"},\n    )\nexcept AretticApiError as err:\n    print(err.status, err.code, err.message, err.doc_url)\n    if err.code == \"insufficient_credits\":\n        ...  # 402, not retryable: top up, or lower max_price.\n    elif err.retryable:\n        ...  # a read or an execute was already retried; a plain POST you send again yourself,\n        # after err.retry_after seconds when it is set"
    },
    {
      "type": "h2",
      "id": "retries",
      "text": "Retries"
    },
    {
      "type": "p",
      "text": "The SDKs retry only what cannot do harm twice. A read can always be sent again. `execute` can, because every call carries an idempotency key and the API answers a repeated key with the first purchase, never a second one. Nothing else is retried: `recommend`, disputes and every org write go out once, and you decide what to do when `retryable` is true."
    },
    {
      "type": "table",
      "caption": "The retry policy",
      "head": [
        "Question",
        "TypeScript",
        "Python"
      ],
      "rows": [
        [
          "Which calls",
          "every `GET`, and `execute`",
          "every `GET`, and `execute`"
        ],
        [
          "On which errors",
          "Those with `retryable` true: the API's flag on the envelope (for example `rate_limited`, `provider_error`, `request_in_progress`, `internal_error`), plus `network_error` and `timeout`. Never an error the API marks not retryable, even a 429 such as `aup_limit`.",
          "the same"
        ],
        [
          "How many times",
          "`maxRetries`, default 3, so up to 4 attempts",
          "`max_retries`, default 3"
        ],
        [
          "Wait without Retry-After",
          "A random point in the top half of a step that doubles from 0.5 s and stops at 8 s: 250 to 500 ms, then 500 ms to 1 s, 1 to 2 s, 2 to 4 s, and 4 to 8 s after that. The jitter keeps many clients from retrying in step.",
          "the same numbers"
        ],
        [
          "Wait with Retry-After",
          "exactly what the header says",
          "the longer of the header and the backoff step"
        ],
        [
          "Retry-After over 60 s",
          "gives up at once; the error carries `retryAfter`, so you decide",
          "the same, with `retry_after`"
        ],
        [
          "Timeouts",
          "`timeoutMs` per attempt, default 60 s, from connect to the end of the body. A timed-out attempt is retried like a network error.",
          "`timeout`, default 60 s, for the connect and for each wait for data. Retried on reads and `execute`."
        ],
        [
          "Per call",
          "`{ maxRetries, timeoutMs, signal }` as the last argument",
          "set on the client"
        ]
      ]
    },
    {
      "type": "h3",
      "id": "idempotency",
      "text": "Idempotency keys on execute"
    },
    {
      "type": "p",
      "text": "Every `execute` body goes out with an `idempotency_key`. The TypeScript client takes it from the `idempotencyKey` option, then from `body.idempotency_key`, then makes a UUID for the call. The Python client takes the `idempotency_key` argument, then the key in the body, then makes a UUID. The same key is reused on every retry of that call, and each new call gets a new key."
    },
    {
      "type": "p",
      "text": "The API answers a repeated key with the first request's answer, marked `replayed: true`, and charges nothing. If the first request is still running it answers 409 `request_in_progress`, which is retryable, so the SDK waits and asks again. A repeated key with a different body is refused with 409 `idempotency_conflict`."
    },
    {
      "type": "p",
      "text": "A generated key lives in memory, so it protects you from a retried request, not from a restarted program. When a purchase must happen once even across restarts, pass your own key, such as the id of the order or row it is for."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "await arettic.execute(\n  { tool_id: toolId, input: { email: \"jason@acme.com\" } },\n  { idempotencyKey: \"order-42-verify\" },\n);"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "client.execute({\"tool_id\": tool_id, \"input\": {\"email\": \"jason@acme.com\"}}, idempotency_key=\"order-42-verify\")"
    },
    {
      "type": "h2",
      "id": "examples",
      "text": "End to end"
    },
    {
      "type": "p",
      "text": "One program per language: recommend, buy, wait if an owner has to approve, read the answer, then the receipt and the balance. Run it with a test key first (`ARETTIC_API_KEY=sk_test_…`): recommend then returns `mock-verify-email`, nothing is charged, and there is no receipt. Switch to a live key and the same code buys the real result and gets one."
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { Arettic, AretticApiError } from \"@arettic/sdk\";\n\nconst arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY, baseUrl: \"https://api.arettic.com\" });\n\nasync function main() {\n  // 1. Which tool works for this task? The rank ignores whether you can buy, so take the first purchasable one.\n  const { options } = await arettic.recommend({ task_type: \"verify_email\", region: \"US\" });\n  const tool = options.find((o) => o.purchasable);\n  if (!tool) throw new Error(\"No purchasable tool for verify_email\");\n\n  // 2. Buy one result.\n  const request = { tool_id: tool.tool_id, input: { email: \"jason@acme.com\" } };\n  let bought = await arettic.execute(request);\n\n  // 3. Over the approval threshold? Wait for an owner, then send the same request with approval_id.\n  if (bought.status === \"approval_required\") {\n    const approval = await arettic.waitForApproval(bought.approval_id);\n    if (approval.status !== \"approved\") throw new Error(\"Approval \" + approval.status);\n    bought = await arettic.execute({ ...request, approval_id: approval.approval_id });\n  }\n\n  // 4. Read the answer.\n  switch (bought.status) {\n    case \"passed\":\n    case \"partial\":\n      console.log(bought.result, bought.charged); // live: { credits: \"7\", usd: \"0.007\" }; test: \"0\"\n      break;\n    case \"failed\":\n      console.log(\"Not charged:\", bought.reason);\n      break;\n    case \"queued\": // only when inputs has more than 25 items\n      console.log(await arettic.waitForJob(bought.job_id));\n      break;\n  }\n\n  // 5. A live key gets a receipt. Something wrong with a charged item? Dispute it within 7 days.\n  if (\"receipt_id\" in bought) {\n    const receipt = await arettic.receipt(bought.receipt_id);\n    console.log(receipt.items[0]?.provider, receipt.items[0]?.outcome, receipt.check_version);\n    // await arettic.openDispute({ receipt_id: receipt.receipt_id, item_index: 0, reason: \"wrong_result\" });\n  }\n\n  console.log(await arettic.balance()); // the org's credits, and this agent's spend and budget\n}\n\nmain().catch((err) => {\n  if (err instanceof AretticApiError) console.error(err.status, err.code, err.message);\n  else console.error(err);\n  process.exit(1);\n});"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "import os\nimport time\n\nfrom arettic import Arettic, AretticApiError\n\nclient = Arettic(api_key=os.environ[\"ARETTIC_API_KEY\"], base_url=\"https://api.arettic.com\")\n\n\ndef main() -> None:\n    # 1. Which tool works for this task? The rank ignores whether you can buy, so take the first purchasable one.\n    rec = client.recommend(task_type=\"verify_email\", region=\"US\")\n    tool_id = next(o[\"tool_id\"] for o in rec[\"options\"] if o[\"purchasable\"])\n\n    # 2. Buy one result.\n    request = {\"tool_id\": tool_id, \"input\": {\"email\": \"jason@acme.com\"}}\n    bought = client.execute(request)\n\n    # 3. Over the approval threshold? Wait for an owner, then send the same request with approval_id.\n    if bought[\"status\"] == \"approval_required\":\n        approval = client.approval(bought[\"approval_id\"])\n        while approval[\"status\"] == \"pending\":\n            time.sleep(5)\n            approval = client.approval(bought[\"approval_id\"])\n        if approval[\"status\"] != \"approved\":\n            raise RuntimeError(\"Approval \" + approval[\"status\"])\n        bought = client.execute({**request, \"approval_id\": approval[\"approval_id\"]})\n\n    # 4. Read the answer.\n    status = bought[\"status\"]\n    if status in (\"passed\", \"partial\"):\n        print(bought[\"result\"], bought[\"charged\"])  # live: {\"credits\": \"7\", \"usd\": \"0.007\"}; test: \"0\"\n    elif status == \"failed\":\n        print(\"Not charged:\", bought[\"reason\"])\n    elif status == \"queued\":  # only when inputs has more than 25 items\n        print(client.wait_for_job(bought[\"job_id\"]))\n\n    # 5. A live key gets a receipt. Something wrong with a charged item? Dispute it within 7 days.\n    if \"receipt_id\" in bought:\n        receipt = client.receipt(bought[\"receipt_id\"])\n        item = receipt[\"items\"][0]\n        print(item[\"provider\"], item[\"outcome\"], receipt[\"check_version\"])\n        # client.open_dispute(receipt_id=receipt[\"receipt_id\"], item_index=0, reason=\"wrong_result\")\n\n    print(client.balance())  # the org's credits, and this agent's spend and budget\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except AretticApiError as err:\n        raise SystemExit(f\"{err.status} {err.code}: {err.message}\")"
    },
    {
      "type": "code",
      "title": "Response (example): balance",
      "lang": "json",
      "code": "{\n  \"org_balance\": {\n    \"paid\": { \"credits\": \"20000\", \"usd\": \"20.000\" },\n    \"trial\": { \"credits\": \"993\", \"usd\": \"0.993\" },\n    \"total\": { \"credits\": \"20993\", \"usd\": \"20.993\" }\n  },\n  \"agent_spent_month\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"agent_budget\": { \"credits\": \"50000\", \"usd\": \"50.000\" },\n  \"agent_budget_left\": { \"credits\": \"49993\", \"usd\": \"49.993\" }\n}"
    },
    {
      "type": "h2",
      "id": "versions",
      "text": "Versions and exports"
    },
    {
      "type": "p",
      "text": "Both clients are at version 0.1.0 and sit on the API described on these pages; changes land on the [changelog](/changelog). `@arettic/sdk` exports `Arettic`, `AretticOrg`, `AretticApiError`, `VERSION`, `DEFAULT_BASE_URL`, `MAX_RETRY_AFTER_SECONDS` (60), the option types `ClientOptions`, `RequestOptions`, `ExecuteOptions`, `WaitOptions` and `OrgClientOptions`, and every request and response type. `arettic` exports `Arettic`, `AretticOrg`, `AretticApiError` and `__version__`."
    },
    {
      "type": "list",
      "items": [
        "[Execute](/docs/execute): every request field, every reason code, `max_price` and `fallback`.",
        "[Jobs](/docs/jobs): batches over 25 inputs, the job object in every state, the `job.completed` event.",
        "[Receipts](/docs/receipts): the receipt object field by field, and the CSV export columns.",
        "[Budgets and approvals](/docs/budgets-and-approvals): thresholds, the approval flow, expiry.",
        "[Org API](/docs/org-api): every org endpoint next to its dashboard action.",
        "[Error codes](/docs/errors): what each code means and how to fix it.",
        "[Rate limits](/docs/rate-limits): the limits and headers the retry policy reacts to."
      ]
    }
  ]
}