Getting started

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 shows the curl calls, and 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 and receipts pages is what comes back.

Install

npm
npm install @arettic/sdk
pip
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.

What each package needs
PackageRuntimeDependenciesVersion
@arettic/sdkNode 18 or newer, Deno, Bun or a browser: anything with a global fetch. ESM only.None.0.1.0
aretticPython 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
VariableRead byWhat it does
ARETTIC_API_KEYBoth clientsThe 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_URLBoth clientsThe API's address when you pass no baseUrl (base_url in Python). Default https://api.arettic.com. Trailing slashes are dropped.
ARETTIC_ORG_IDPython AretticOrg onlyThe 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
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
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
TypeScriptPythonDefaultWhat it does
apiKeyapi_keyARETTIC_API_KEYThe key sent as Authorization: Bearer. TypeScript leaves it off public data calls; Python sends it on every call once it has one.
baseUrlbase_urlARETTIC_API_URL, then https://api.arettic.comWhere requests go. Give the API's own address, with no path.
maxRetriesmax_retries3How many times a safe request is sent again after a retryable error. 0 sends it once. See retries.
timeoutMstimeout60 000 ms in TypeScript, 60.0 s in PythonTypeScript: 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 fetchTypeScript 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
TypeScriptPythonCalls
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 page says what each returns and under what licence.

Recommend, execute and everything after

Agent methods
TypeScriptPythonCalls
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.
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.
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.
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.
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)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
statusWhenWhat else is in the answer
passedOne input, and the result passed its checkresult, charged, refunded, execution_id, check_version, and receipt_id with a live key.
partialOne input, and part of the result passed (web search)result, reason, and a pro-rata charged.
failedOne input, and the check failed or the provider erroredreason only. No result, and nothing charged.
completedinputs with up to 25 items, all runsummary (items, passed, partial, failed) and items[], each with its own index, status, charged, and result or reason.
approval_requiredThe 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.
queuedinputs 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 page.

Response (example): test key, one input
{
  "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": "[email protected]", "status": "valid" },
  "would_have_charged": { "credits": "6", "usd": "0.006" }
}
Response (example): live key, one input
{
  "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": "[email protected]", "status": "valid" }
}
Response (example): approval needed, HTTP 202
{
  "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
{
  "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
HelperPollsReturns whenDefaults
waitForJob(id, { intervalMs, timeoutMs, signal })GET /v1/jobs/{id}status is done, failed or expiredevery 2 s, for up to 10 minutes
wait_for_job(id, interval=2.0, timeout=600.0)GET /v1/jobs/{id}the sameevery 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 usedevery 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
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
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
{
  "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 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 page lists every endpoint next to its dashboard action.

TypeScript
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
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
NamespaceTypeScriptPythonEndpoints under /v1/orgs/{orgId}
dashboarddashboard({ days })dashboard(days=None)GET /dashboard: balance, spend by day, agent and tool, items charged and not charged, budgets and pending items.
balancebalance()balance()GET /balance: paid and trial credits.
planplan()plan()GET /plan: the plan, its limits, today's usage and subscriptions.
eventsevents({ limit })events(limit=None)GET /events: recent events.
agentslist(), 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.
approvalslist({ 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.
receiptslist(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.
disputeslist({ status }), create(body)list(status=None), create(**fields)GET /disputes (open first), POST /disputes with the same body as the agent's openDispute.
invoiceslist(), get(id)list(), get(id, format=None)GET /invoices, GET /invoices/{id}. In Python, format="html" returns the printable invoice as text.
topupslist(), 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_reloadget(), set(body)get(), set(**fields)GET /auto-reload, PUT /auto-reload with enabled, threshold_usd, amount_usd. Turning it on needs a saved card.
notificationsset({ low_balance_usd })set(low_balance_usd)PUT /notifications: the balance under which owners get balance.low.
subscriptionsstart(body), cancel(plan)start(**fields), cancel(plan)POST /subscriptions with plan, interval, founding; DELETE /subscriptions/{plan}.
billingupdate(body)update(**fields)PATCH /billing: tax_id, country, billing_name, billing_address.
webhookslist(), 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.
memberslist(), 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_requestslist()list()GET /deletion-requests. Asking for a deletion needs a signed-in owner, not a key.
testSets / test_setslist(), 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.
benchmarkslist({ 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}.
providerget(), 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
// 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
# 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
HeaderValueSent on
AuthorizationBearer <key>Every keyed call. TypeScript omits it on public data calls; Python sends it whenever the client has a key.
Content-Typeapplication/jsonEvery call with a body.
Acceptapplication/json; TypeScript sends text/csv, text/plain, */* for the CSV exportEvery call.
User-Agentarettic-sdk-ts/0.1.0, after your userAgent if you set one; or arettic-sdk-python/0.1.0Every call.
curl: the request the SDKs make for execute
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": "[email protected]" },
    "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 page, and docUrl points at the right entry.

Response (example): HTTP 402
{
  "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
TypeScriptPythonHolds
statusstatusThe HTTP status, or 0 when no response arrived.
codecodeThe error code, for example insufficient_credits. Link to it as /docs/errors#insufficient_credits.
messagemessageThe API's own sentence about what went wrong and what to do. Python's str(err) is message (HTTP 402, insufficient_credits).
docUrldoc_urlThe docs entry for the code.
retryableretryableWhether sending the same request again later can succeed. The API sets it per code, and the SDK retries on it (below).
retryAfterretry_afterSeconds 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.
bodybodyThe 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 page too.

Codes the SDKs raise without an API envelope
codestatusretryableWhen
network_error0yesNo response at all: DNS failed, the connection was refused or reset, or the reply was not HTTP.
timeout0yesNo 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.
aborted0noTypeScript only: your AbortSignal fired.
unauthenticated0noTypeScript 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_redirectthe redirect's own statusnoPython 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_errorthe response's statusyes for 429 and 5xxThe 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
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
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
QuestionTypeScriptPython
Which callsevery GET, and executeevery GET, and execute
On which errorsThose 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 timesmaxRetries, default 3, so up to 4 attemptsmax_retries, default 3
Wait without Retry-AfterA 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-Afterexactly what the header saysthe longer of the header and the backoff step
Retry-After over 60 sgives up at once; the error carries retryAfter, so you decidethe same, with retry_after
TimeoutstimeoutMs 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 argumentset 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
await arettic.execute(
  { tool_id: toolId, input: { email: "[email protected]" } },
  { idempotencyKey: "order-42-verify" },
);
Python
client.execute({"tool_id": tool_id, "input": {"email": "[email protected]"}}, 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
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: "[email protected]" } };
  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
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": "[email protected]"}}
    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
{
  "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. @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__.

Updated 2026-09-29 · This page as Markdown · JSON