# SDKs

The TypeScript and Python clients: install, the agent client, the org client, errors and retries.

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.

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.

## Install

**npm**

```bash
npm install @arettic/sdk
```

**pip**

```bash
pip install arettic
```

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

What each package needs

| Package | Runtime | Dependencies | Version |
|---|---|---|---|
| @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 |

Both clients read their settings from the environment when you pass nothing, so a key never has to live in code.

Environment variables the clients read

| Variable | Read by | What it does |
|---|---|---|
| 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. |

## The agent client

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

**TypeScript**

```ts
import { Arettic } from "@arettic/sdk";

const arettic = new Arettic({
  apiKey: process.env.ARETTIC_API_KEY,
  baseUrl: "https://api.arettic.com",
  maxRetries: 3, // the default
  timeoutMs: 60_000, // per attempt; the default
  userAgent: "research-bot/1.0", // goes in front of arettic-sdk-ts/0.1.0
});
```

**Python**

```python
import os

from arettic import Arettic

client = Arettic(
    api_key=os.environ["ARETTIC_API_KEY"],
    base_url="https://api.arettic.com",
    max_retries=3,  # the default
    timeout=60.0,  # seconds; the default
)
```

Constructor options

| TypeScript | Python | Default | What it does |
|---|---|---|---|
| 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`. |

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.

### Public data, no key needed

Public data methods

| TypeScript | Python | Calls |
|---|---|---|
| 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. |

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.

### Recommend, execute and everything after

Agent methods

| TypeScript | Python | Calls |
|---|---|---|
| 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": {...}}`. |

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

### Reading the answer from execute

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

The six execute answers

| status | When | What else is in the answer |
|---|---|---|
| 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. |

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.

**Response (example): test key, one input**

```json
{
  "test_mode": true,
  "tool_id": "mock-verify-email",
  "task_type": "verify_email",
  "check_version": "v1",
  "charged": { "credits": "0", "usd": "0.000" },
  "status": "passed",
  "result": { "email": "jason@acme.com", "status": "valid" },
  "would_have_charged": { "credits": "6", "usd": "0.006" }
}
```

**Response (example): live key, one input**

```json
{
  "status": "passed",
  "execution_id": "6b1d3f0e-2c4a-4f8e-9a7b-1c2d3e4f5a6b",
  "receipt_id": "0f7a9c2d-5e6b-4a1c-8d9e-2f3a4b5c6d7e",
  "tool_id": "hunter-verify-email",
  "task_type": "verify_email",
  "check_version": "v1",
  "charged": { "credits": "7", "usd": "0.007" },
  "refunded": { "credits": "0", "usd": "0.000" },
  "result": { "email": "jason@acme.com", "status": "valid" }
}
```

**Response (example): approval needed, HTTP 202**

```json
{
  "status": "approval_required",
  "approval_id": "a1c2e3f4-5b6a-4d7c-8e9f-0a1b2c3d4e5f",
  "reason": "over_threshold",
  "amount": { "credits": "25000", "usd": "25.000" },
  "expires_at": "2026-09-30T09:12:45.000Z",
  "message": "This is above the agent's approval threshold. An owner has been asked to approve it; retry with approval_id once approved."
}
```

**Response (example): queued as a job, HTTP 202**

```json
{
  "status": "queued",
  "job_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "items": 100,
  "max_charge": { "credits": "700", "usd": "0.700" },
  "poll": "/v1/jobs/9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "message": "Running 100 items as a job. Poll the job for progress; each item is charged only if it passes."
}
```

## Waiting for jobs and approvals

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.

Polling helpers

| Helper | Polls | Returns when | Defaults |
|---|---|---|---|
| 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 |

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.

**TypeScript**

```ts
const inputs = [
  { first_name: "Emily", last_name: "Carter", domain: "acme.com" },
  { first_name: "Jason", last_name: "Miller", domain: "example.com" },
  // ... up to 1,000
];
let bought = await arettic.execute({ tool_id: toolId, inputs });

if (bought.status === "approval_required") {
  const approval = await arettic.waitForApproval(bought.approval_id, { intervalMs: 10_000 });
  if (approval.status !== "approved") throw new Error("Approval " + approval.status);
  // The same tool and inputs, or the API answers approval_mismatch.
  bought = await arettic.execute({ tool_id: toolId, inputs, approval_id: approval.approval_id });
}

if (bought.status === "queued") {
  const job = await arettic.waitForJob(bought.job_id, { timeoutMs: 30 * 60_000 });
  console.log(job.status, job.summary, job.receipt_id);
}
```

**Python**

```python
import time

inputs = [
    {"first_name": "Emily", "last_name": "Carter", "domain": "acme.com"},
    {"first_name": "Jason", "last_name": "Miller", "domain": "example.com"},
    # ... up to 1,000
]
bought = client.execute(tool_id=tool_id, inputs=inputs)

if bought["status"] == "approval_required":
    approval = client.approval(bought["approval_id"])
    while approval["status"] == "pending":
        time.sleep(10)
        approval = client.approval(bought["approval_id"])
    if approval["status"] != "approved":
        raise RuntimeError("Approval " + approval["status"])
    # The same tool and inputs, or the API answers approval_mismatch.
    bought = client.execute(tool_id=tool_id, inputs=inputs, approval_id=approval["approval_id"])

if bought["status"] == "queued":
    job = client.wait_for_job(bought["job_id"], interval=5.0, timeout=1800.0)
    print(job["status"], job.get("summary"), job.get("receipt_id"))
```

**Response (example): a job while it runs**

```json
{
  "job_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "status": "running",
  "items": 100,
  "progress": 37,
  "created_at": "2026-09-29T09:12:45.000Z",
  "started_at": "2026-09-29T09:12:47.000Z",
  "finished_at": null
}
```

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.

## The org client

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

**TypeScript**

```ts
import { AretticOrg } from "@arettic/sdk";

const org = new AretticOrg({
  apiKey: process.env.ARETTIC_ORG_KEY, // with none, ARETTIC_API_KEY
  orgId: "your-org-id", // required: the constructor throws without it
  baseUrl: "https://api.arettic.com",
  // maxRetries, timeoutMs, fetch and userAgent work as on Arettic
});
```

**Python**

```python
import os

from arettic import AretticOrg

# Positional: key, then org id. With neither, ARETTIC_API_KEY and ARETTIC_ORG_ID are read;
# without an org id the constructor raises ValueError.
org = AretticOrg(os.environ["ARETTIC_ORG_KEY"], "your-org-id", base_url="https://api.arettic.com")
```

Org client namespaces

| Namespace | TypeScript | Python | Endpoints under /v1/orgs/{orgId} |
|---|---|---|---|
| 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`. |

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

**TypeScript**

```ts
// A test agent. Its key is in the answer once; store it now.
const { agent, key } = await org.agents.create({ name: "research bot", mode: "test" });
console.log(key); // sk_test_…

// A $50 monthly budget, and ask an owner before any single purchase over $20.
await org.agents.update(agent.id, {
  monthly_budget_credits: 50_000,
  approval_threshold_credits: 20_000,
});

// Approve what is waiting.
for (const a of await org.approvals.list({ status: "pending" })) {
  await org.approvals.decide(a.approval_id, { decision: "approve", note: "ok" });
}

// September's receipts, page by page, then the same month as CSV.
const month = { from: "2026-09-01", to: "2026-09-30" };
let page = await org.receipts.list({ ...month, limit: 50 });
const receipts = [...page.receipts];
while (page.next) {
  page = await org.receipts.list({ ...month, before: page.next });
  receipts.push(...page.receipts);
}
const csv = await org.receipts.exportCsv(month);

// A webhook for finished jobs and decided disputes. The secret is shown once.
const { webhook } = await org.webhooks.create({
  url: "https://agent.example.com/arettic",
  events: ["job.completed", "dispute.decided"],
});
console.log(webhook.secret);
```

**Python**

```python
# A test agent. Its key is in the answer once; store it now.
made = org.agents.create(name="research bot", mode="test")
print(made["key"])  # sk_test_…

# A $50 monthly budget, and ask an owner before any single purchase over $20.
org.agents.update(made["agent"]["id"], monthly_budget_credits=50_000, approval_threshold_credits=20_000)

# Approve what is waiting.
for a in org.approvals.list(status="pending")["approvals"]:
    org.approvals.decide(a["approval_id"], "approve", note="ok")

# September's receipts, page by page, then the same month as CSV.
month = {"from_": "2026-09-01", "to": "2026-09-30"}
page = org.receipts.list(limit=50, **month)
receipts = list(page["receipts"])
while page["next"]:
    page = org.receipts.list(before=page["next"], **month)
    receipts.extend(page["receipts"])
csv_text = org.receipts.export_csv(from_="2026-09-01", to="2026-09-30")

# A webhook for finished jobs and decided disputes. The secret is shown once.
hook = org.webhooks.create("https://agent.example.com/arettic", events=["job.completed", "dispute.decided"])
print(hook["webhook"]["secret"])
```

## What the SDK sends

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.

Request headers

| Header | Value | Sent on |
|---|---|---|
| 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. |

**curl: the request the SDKs make for execute**

```bash
curl https://api.arettic.com/v1/execute \
  -H "Authorization: Bearer $ARETTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "User-Agent: arettic-sdk-ts/0.1.0" \
  -d '{
    "tool_id": "mock-verify-email",
    "input": { "email": "jason@acme.com" },
    "idempotency_key": "b3b0d3a2-6d5e-4f1a-9c2b-7e8f9a0b1c2d"
  }'
```

## Errors

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.

**Response (example): HTTP 402**

```json
{
  "status": "declined",
  "error": {
    "code": "insufficient_credits",
    "message": "This needs 7 credits ($0.007); the org has 0 ($0.000). Top up to continue.",
    "doc_url": "https://arettic.com/docs/errors#insufficient_credits",
    "retryable": false
  }
}
```

AretticApiError fields

| TypeScript | Python | Holds |
|---|---|---|
| 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`. |

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.

Codes the SDKs raise without an API envelope

| code | status | retryable | When |
|---|---|---|---|
| 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. |

**TypeScript**

```ts
import { AretticApiError } from "@arettic/sdk";

try {
  await arettic.execute({
    tool_id: "mock-find-email",
    input: { first_name: "Emily", last_name: "Carter", domain: "acme.com" },
  });
} catch (err) {
  if (!(err instanceof AretticApiError)) throw err;
  console.error(err.status, err.code, err.message, err.docUrl);
  if (err.code === "insufficient_credits") {
    // 402, not retryable: top up, or lower max_price.
  } else if (err.retryable) {
    // A read or an execute was already retried. A plain POST like recommend was sent once:
    // wait err.retryAfter seconds (or a moment) and send it again yourself.
  }
}
```

**Python**

```python
from arettic import AretticApiError

try:
    client.execute(
        tool_id="mock-find-email",
        input={"first_name": "Emily", "last_name": "Carter", "domain": "acme.com"},
    )
except AretticApiError as err:
    print(err.status, err.code, err.message, err.doc_url)
    if err.code == "insufficient_credits":
        ...  # 402, not retryable: top up, or lower max_price.
    elif err.retryable:
        ...  # a read or an execute was already retried; a plain POST you send again yourself,
        # after err.retry_after seconds when it is set
```

## Retries

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.

The retry policy

| Question | TypeScript | Python |
|---|---|---|
| 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 |

### Idempotency keys on execute

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.

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

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.

**TypeScript**

```ts
await arettic.execute(
  { tool_id: toolId, input: { email: "jason@acme.com" } },
  { idempotencyKey: "order-42-verify" },
);
```

**Python**

```python
client.execute({"tool_id": tool_id, "input": {"email": "jason@acme.com"}}, idempotency_key="order-42-verify")
```

## End to end

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.

**TypeScript**

```ts
import { Arettic, AretticApiError } from "@arettic/sdk";

const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY, baseUrl: "https://api.arettic.com" });

async function main() {
  // 1. Which tool works for this task? The rank ignores whether you can buy, so take the first purchasable one.
  const { options } = await arettic.recommend({ task_type: "verify_email", region: "US" });
  const tool = options.find((o) => o.purchasable);
  if (!tool) throw new Error("No purchasable tool for verify_email");

  // 2. Buy one result.
  const request = { tool_id: tool.tool_id, input: { email: "jason@acme.com" } };
  let bought = await arettic.execute(request);

  // 3. Over the approval threshold? Wait for an owner, then send the same request with approval_id.
  if (bought.status === "approval_required") {
    const approval = await arettic.waitForApproval(bought.approval_id);
    if (approval.status !== "approved") throw new Error("Approval " + approval.status);
    bought = await arettic.execute({ ...request, approval_id: approval.approval_id });
  }

  // 4. Read the answer.
  switch (bought.status) {
    case "passed":
    case "partial":
      console.log(bought.result, bought.charged); // live: { credits: "7", usd: "0.007" }; test: "0"
      break;
    case "failed":
      console.log("Not charged:", bought.reason);
      break;
    case "queued": // only when inputs has more than 25 items
      console.log(await arettic.waitForJob(bought.job_id));
      break;
  }

  // 5. A live key gets a receipt. Something wrong with a charged item? Dispute it within 7 days.
  if ("receipt_id" in bought) {
    const receipt = await arettic.receipt(bought.receipt_id);
    console.log(receipt.items[0]?.provider, receipt.items[0]?.outcome, receipt.check_version);
    // await arettic.openDispute({ receipt_id: receipt.receipt_id, item_index: 0, reason: "wrong_result" });
  }

  console.log(await arettic.balance()); // the org's credits, and this agent's spend and budget
}

main().catch((err) => {
  if (err instanceof AretticApiError) console.error(err.status, err.code, err.message);
  else console.error(err);
  process.exit(1);
});
```

**Python**

```python
import os
import time

from arettic import Arettic, AretticApiError

client = Arettic(api_key=os.environ["ARETTIC_API_KEY"], base_url="https://api.arettic.com")


def main() -> None:
    # 1. Which tool works for this task? The rank ignores whether you can buy, so take the first purchasable one.
    rec = client.recommend(task_type="verify_email", region="US")
    tool_id = next(o["tool_id"] for o in rec["options"] if o["purchasable"])

    # 2. Buy one result.
    request = {"tool_id": tool_id, "input": {"email": "jason@acme.com"}}
    bought = client.execute(request)

    # 3. Over the approval threshold? Wait for an owner, then send the same request with approval_id.
    if bought["status"] == "approval_required":
        approval = client.approval(bought["approval_id"])
        while approval["status"] == "pending":
            time.sleep(5)
            approval = client.approval(bought["approval_id"])
        if approval["status"] != "approved":
            raise RuntimeError("Approval " + approval["status"])
        bought = client.execute({**request, "approval_id": approval["approval_id"]})

    # 4. Read the answer.
    status = bought["status"]
    if status in ("passed", "partial"):
        print(bought["result"], bought["charged"])  # live: {"credits": "7", "usd": "0.007"}; test: "0"
    elif status == "failed":
        print("Not charged:", bought["reason"])
    elif status == "queued":  # only when inputs has more than 25 items
        print(client.wait_for_job(bought["job_id"]))

    # 5. A live key gets a receipt. Something wrong with a charged item? Dispute it within 7 days.
    if "receipt_id" in bought:
        receipt = client.receipt(bought["receipt_id"])
        item = receipt["items"][0]
        print(item["provider"], item["outcome"], receipt["check_version"])
        # client.open_dispute(receipt_id=receipt["receipt_id"], item_index=0, reason="wrong_result")

    print(client.balance())  # the org's credits, and this agent's spend and budget


if __name__ == "__main__":
    try:
        main()
    except AretticApiError as err:
        raise SystemExit(f"{err.status} {err.code}: {err.message}")
```

**Response (example): balance**

```json
{
  "org_balance": {
    "paid": { "credits": "20000", "usd": "20.000" },
    "trial": { "credits": "993", "usd": "0.993" },
    "total": { "credits": "20993", "usd": "20.993" }
  },
  "agent_spent_month": { "credits": "7", "usd": "0.007" },
  "agent_budget": { "credits": "50000", "usd": "50.000" },
  "agent_budget_left": { "credits": "49993", "usd": "49.993" }
}
```

## Versions and exports

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__`.

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

Updated 2026-09-29. This page as HTML: https://arettic.com/docs/sdks · Markdown: https://arettic.com/docs/sdks.md · JSON: https://arettic.com/docs/sdks.json
