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 install @arettic/sdk
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.
| 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.
| 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.
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
});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
)| 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. |
| 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
| 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 page says what each returns and under what licence.
Recommend, execute and everything after
| 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. |
| 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.
| 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 page.
{
"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" }
}{
"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" }
}{
"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."
}{
"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.
| 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.
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);
}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")){
"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.
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
});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")
| 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. |
| 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.
// 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);# 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.
| 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 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.
{
"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
}
}| 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. |
| 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 page too.
| 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. |
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.
}
}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 setRetries
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.
| 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.
await arettic.execute(
{ tool_id: toolId, input: { email: "[email protected]" } },
{ idempotencyKey: "order-42-verify" },
);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.
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);
});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}"){
"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__.
- Execute: every request field, every reason code,
max_priceandfallback. - Jobs: batches over 25 inputs, the job object in every state, the
job.completedevent. - Receipts: the receipt object field by field, and the CSV export columns.
- Budgets and approvals: thresholds, the approval flow, expiry.
- Org API: every org endpoint next to its dashboard action.
- Error codes: what each code means and how to fix it.
- Rate limits: the limits and headers the retry policy reacts to.