# Arettic: full documentation # Arettic: describe the result, we find the tool that delivers it Every tool, scored. Every result, guaranteed. Your agent says what it needs. Arettic ranks the tools by public scores, runs the best one, checks what comes back, and refunds you when it fails. Sales and research data today. Join the waitlist: POST https://api.arettic.com/v1/waitlist {"email": "you@company.com"} (no key), or use the form at https://arettic.com/waitlist. ## Why Arettic - **Pay only for passes.** A bounced email, no match or an empty scrape is refunded to your credits in seconds. Your agent gets the reason; you get a receipt. - **Public scores.** Every tool gets a 0 to 100 score for each task and region. Every input and the formula are published. - **Neutral ranking.** Recommend ranks every tool for the task, including ones we don't sell. ## How it works 1. **Ask** (POST /v1/recommend): your agent names the task and region and gets tools ranked by score, then price. Free; covers tools we don't sell. 2. **Run** (POST /v1/execute): one key runs every curated tool. Budget and approval rules are checked, then credits are held. 3. **Check**: the result meets the task's published pass rule or it doesn't; the rule version is on the receipt. 4. **Settle**: pass, credits captured and the result returned. Fail, hold released, reason returned, data withheld. ## What your agent can do today Six task types are live: find_email, verify_email, enrich_company, enrich_person, web_search, extract_url. A new task type goes live only when its result can be checked automatically and scored against known answers. ## Scores Score = 100 x (0.40 accuracy + 0.25 pass rate + 0.20 audit precision + 0.10 reliability + 0.05 speed), minus a penalty for upheld disputes (up to 20 points). The formula and every input are at https://arettic.com/docs/scores. ## Spending limits Your agent can't overspend: $50 monthly budget per agent by default, approval above a threshold ($20 by default), instant key revoke, optional IP allowlist. ## Pass rules (v1) One published check for each live task type. | task_type | Used for | Passes if | Partial or failed | |---|---|---|---| | find_email | Outbound prospecting, recruiting outreach, partner sourcing | An email is returned and its verification status is valid (not catch-all or unknown) | Catch-all counts as a fail and is refunded | | verify_email | Cleaning a list before a campaign, sign-up and CRM hygiene | The verifier returns a definitive status (valid or invalid) | Unknown or timeout counts as a fail | | enrich_company | Account research, lead scoring and routing, CRM enrichment | The record matches the requested domain (or name + country) and has name, domain, employee range and industry | Missing required fields counts as a fail | | enrich_person | Lead qualification, contact research, candidate and investor research | Name (fuzzy ≥ 0.9) and company domain (exact) match the input; a title and one contact field are present | A match without a contact field counts as a fail | | web_search | Market and competitor research, news monitoring, building target lists | At least N results (5 by default) with valid, non-duplicate URLs | Fewer than N is charged pro rata | | extract_url | Reading pricing pages, docs, job posts and filings into an agent | HTTP 200 with at least 200 characters of main content, not a block or captcha page | A blocked page counts as a fail | Invalid input is rejected before any provider call, free. ## Pricing | Plan | Price | |---|---| | Pay as you go | $0 + credits | | Pro | $99 a month or $990 a year | | Max | From $1,000 a month | 1 credit = $0.001. New accounts get $1 of credits when they sign up and $10 more after a demo call. This page as HTML: https://arettic.com/ · Markdown: https://arettic.com/index.md · JSON: https://arettic.com/index.json --- # Pricing: pay for results, not calls You pay only for results that pass their published check. Failed checks cost nothing. ## Plans | Plan | Price | Agents / seats | Score lookups / day | Free-text lookups / day | |---|---|---|---|---| | Pay as you go | $0 + credits | 2 / 1 | 1000 | 100 | | Pro | $99 a month or $990 a year | 10 / 5 | 10000 | 500 | | Max | From $1,000 a month | Unlimited / Unlimited | Custom | Custom | Founding Pro price: Pro plan at $490 for the first year, for the first 50 orgs, until 30 days after public launch. Renews at $990 a year. Places left: 50. Subscribe with `POST https://api.arettic.com/v1/orgs/{orgId}/subscriptions` and `{"plan": "team", "interval": "year", "founding": true}`. Budgets, approvals, the outcome guarantee and receipts are free on every plan. So are test mode and score lookups. ## How a price is set price per success = C ÷ S_price × k - **C**: the provider's list price per call, re-checked daily. - **S_price**: the lower of the benchmark pass rate and the live pass rate over the last 7 days. - **k**: the plan multiplier (1.5, 1.3 or 1.25; never below 1.25). Prices round up to whole credits (1 credit = $0.001). Worked example, $0.025 cost: pay as you go 38 credits, Pro 33, Max 32. ## Credits - First top-up $20, then $50 minimum. Every top-up comes with an invoice. - Priced and charged in US dollars; any sales tax is shown separately on the invoice. - Paid credits last 12 months; trial credits ($1 on sign-up + $10 after a demo call) are spent first and last 30 days. - Credits can't be transferred or cashed out. ## For providers Free (claim listing, see your score) · Insights $199/month · Pro $999/month. Paying never changes a score, rank or routing decision. This page as HTML: https://arettic.com/pricing · Markdown: https://arettic.com/pricing.md · JSON: https://arettic.com/pricing.json --- # Scores Every tool gets a 0–100 score per task type and region, recomputed every Monday 00:00 UTC. Formula v1: 100 × (0.40A + 0.25S + 0.20P + 0.10R + 0.05L) − min(20, 500D), clamped to 0–100 - **A**: Accuracy: share of benchmark cases where a correct result was delivered - **S**: Pass rate: benchmark + live calls in the last 28 days, weighted by volume - **P**: Audit precision: share of audited passes confirmed correct; uses A until 50 audits exist - **R**: Reliability: 1 − provider error/timeout rate (last 28 days) - **L**: Speed: task-median latency ÷ this tool's latency, capped at 1 - **D**: Upheld disputes ÷ passed results - Rates from fewer than 200 data points use the 95% Wilson lower bound - A score moves at most ±10 points a week unless the tool is paused - Default ranking is score, with price breaking ties within 2 points - Whether a tool can be bought through Arettic never affects its rank - Scores are recomputed every Monday 00:00 UTC ## Live providers _The first scores are published when benchmarks run on real providers._ ## Test mode (mock providers) These are the sandbox tools test keys use. Their scores prove the pipeline; they are not real providers. | Task type | Tool | Provider | Region | Score | A | S | P | R | L | D | Sample | Week | |---|---|---|---|---|---|---|---|---|---|---|---|---| | enrich_company | mock-enrich-company | Arettic Mock Provider | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | | enrich_person | mock-enrich-person | Arettic Mock Provider | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | | extract_url | mock-extract-url | Arettic Mock Provider | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | | find_email | mock-find-email | Arettic Mock Provider | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | | verify_email | mock-verify-email | Arettic Mock Provider | GLOBAL | 62.87 | 0.5958 | 0.5958 | 0.5958 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | | web_search | mock-web-search | Arettic Mock Provider | GLOBAL | 73.63 | 0.7225 | 0.7225 | 0.7225 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | Open data under CC BY 4.0. JSON: https://api.arettic.com/v1/scores This page as HTML: https://arettic.com/scores · Markdown: https://arettic.com/scores.md · JSON: https://arettic.com/scores.json --- # Benchmark reports Every tool runs the same private test set with known answers. We publish the method, the ranking and sample cases. No provider pays to be included or sees the test set. | Report | Status | Task type | Region | |---|---|---|---| | [Which company API actually finds US SaaS firms?](https://arettic.com/reports/us-saas-company-apis.md) | publishing 2026-11-05 | enrich_company | US | Get each report when it's out: POST https://api.arettic.com/v1/waitlist {"email": "you@company.com", "source": "reports"} This page as HTML: https://arettic.com/reports · Markdown: https://arettic.com/reports.md · JSON: https://arettic.com/reports.json --- # For providers Get your API benchmarked, scored and sold through Arettic. Analytics are paid; ranking never is. Arettic tells AI agents which tool works for a job, then sells the results that pass a published check. If you run an API an agent could call, such as email, company or people data, search, page extraction, identity checks, jobs data or weather, your tool can be scored, recommended and bought here. None of that costs you anything. It depends on how well your tool does. ## How your tool is scored Every tool runs the same private test sets with known answers, per task type and region. Nobody outside Arettic sees the test cases, so they can't be tuned against. Benchmarks re-run every month. Live purchases count too: each result is judged by the same published [pass rule](/docs/pass-rules), and upheld customer disputes lower the score. Scores are recomputed weekly, every input and the formula are public, and they're published as open data on [Scores](/scores). ## How agents find you When an agent asks which tool to use, [recommend](/docs/recommend) ranks every tool for the task by score, then price, including tools that aren't sold through Arettic. A better score is the only way up the list. ## What we are adding next Six task types are live: `find_email`, `verify_email`, `enrich_company`, `enrich_person`, `web_search` and `extract_url`. Planned, not live: company search, people search, AI search, brand data, identity checks, jobs data and weather. A task type goes live when its result can be checked automatically. If yours can, tell us what a pass looks like and we'll write the published check with you. We don't list creative output such as images, video or voice, or model routing, because a pass can't be checked automatically. ## Selling through Arettic A tool can be bought through Arettic once a recent benchmark shows at least 60% accuracy and we have resale terms with you. We call your API with our own account, pay your normal price for every call, pass or fail, and reconcile your bill every month. Customers pay per result that passes the check. The price comes from a public formula: your cost, divided by the pass rate, times a plan multiplier. ## Claim your listing Claiming is free. It lets your team see your tool's scores, benchmark results and failure reasons in the dashboard. We check each claim by hand, usually within 2 working days. - Before launch: email us from your company domain and we'll set it up. - After launch: sign in, then `POST /v1/orgs/{orgId}/provider/claims` with `provider` and `evidence` (or the Provider page of the dashboard). - Insights and Pro add segment breakdowns, failure reasons, re-tests, head-to-head comparisons and API access (`GET /v1/orgs/{orgId}/provider`). ## Provider plans | Plan | Price | Includes | |---|---|---| | Free | $0 | Claim your listing; See your public score | | Insights | $199 a month | Segment breakdowns; Failure reasons; One re-test a month | | Pro | $999 a month | Head-to-head comparisons; API access; Demand signals | **Paying never changes a score, rank or routing decision.** Contact: hello@arettic.com · Write from your company domain to claim a listing or talk about resale terms. This page as HTML: https://arettic.com/providers · Markdown: https://arettic.com/providers.md · JSON: https://arettic.com/providers.json --- # Join the waitlist Get the first benchmark report the day it's out, and your invite when self-serve sign-up opens. No key, no CAPTCHA; 10 sign-ups per 1 hour per IP. Only `email` is required. ``` POST https://api.arettic.com/v1/waitlist Content-Type: application/json {"email": "you@company.com", "name": "Emily Carter", "company": "Acme", "use_case": "lead research agent", "source": "my-agent"} ``` Answers: 201 `{"status":"joined"}`, 200 `{"status":"already_joined"}`, 400 `invalid_input`, 429 `rate_limited` (with Retry-After). People can use the form at https://arettic.com/waitlist. This page as HTML: https://arettic.com/waitlist · Markdown: https://arettic.com/waitlist.md · JSON: https://arettic.com/waitlist.json --- # Arettic status Overall: **operational** | Component | Status | |---|---| | API | operational | | Execute | operational | | Recommend | operational | | MCP | operational | | Website | operational | | Payments | operational | ## Incidents (last 90 days) No incidents in the last 90 days. Machine-readable: GET https://api.arettic.com/v1/status (no key). This page as HTML: https://arettic.com/status · Markdown: https://arettic.com/status.md · JSON: https://arettic.com/status.json --- # Docs Arettic gives an agent one key for sales and research data tools. It recommends the tool that works for a task, runs it, checks the result against a published rule and charges only if it passes. Every page here is also Markdown (add .md) and JSON (add .json). ## How it works 1. **Recommend**: POST /v1/recommend ranks the tools for a task by score, then price. Free. 2. **Execute**: POST /v1/execute runs one tool with one key. Credits are held before the call. 3. **Check**: Each item runs the published pass rule for its task type. The rule's version is on the receipt. 4. **Settle**: Pass: credits captured, full result returned. Fail: hold released, reason returned, data withheld. ## Getting started - [Quickstart](https://arettic.com/docs/quickstart.md): From a key to a receipt: recommend a tool, execute it, read the result, and see what you were charged. - [Authentication and keys](https://arettic.com/docs/authentication.md): Agent keys, org keys and sessions: what each can do, how to send them, and how to rotate or revoke them. - [Test mode](https://arettic.com/docs/test-mode.md): Mock providers with fixed answers: build the pass and the fail path without spending a credit. - [MCP server](https://arettic.com/docs/mcp.md): Connect Claude, Cursor or any MCP client: the Streamable HTTP endpoint, the stdio bridge, and the nine tools. - [SDKs](https://arettic.com/docs/sdks.md): The TypeScript and Python clients: install, the agent client, the org client, errors and retries. ## Buying results - [Recommend](https://arettic.com/docs/recommend.md): Ask which tool works for a task and get them ranked by score, then price. - [Execute](https://arettic.com/docs/execute.md): Run a tool on one input or a batch, and pay only for items that pass the published check. - [Batches and jobs](https://arettic.com/docs/jobs.md): More than 25 inputs run as a job: the hold, the states, polling, and the completion webhook. - [Receipts](https://arettic.com/docs/receipts.md): Every purchase ends in an immutable receipt: what was asked, what ran, what it cost, what was refunded. - [Disputes](https://arettic.com/docs/disputes.md): Think a charged result was wrong? Dispute it within 7 days; we decide within 48 hours against the stored result. - [Budgets and approvals](https://arettic.com/docs/budgets-and-approvals.md): Per-agent budgets and approval thresholds, enforced on our side: what happens when a purchase needs a person. - [Pass rules](https://arettic.com/docs/pass-rules.md): Six task types, one published check each: what passes, what fails, and what is refunded. - [How scores work](https://arettic.com/docs/scores.md): The score formula, its six inputs, where each comes from, when scores change and how to cite one. ## Running an org - [Org API: everything the dashboard does](https://arettic.com/docs/org-api.md): Org keys drive every dashboard action through the API: agents, budgets, approvals, receipts, billing, webhooks and team. - [Webhooks and notifications](https://arettic.com/docs/webhooks.md): One event list, delivered as signed webhooks on every plan and emailed to owners where a person should know. - [Credits, top-ups and plans](https://arettic.com/docs/credits.md): The credit unit, balances, top-ups in US dollars, auto-reload, expiry, trial credits and plans. - [Data handling and retention](https://arettic.com/docs/data-handling.md): What we store, for how long, encrypted with what, and how to have it deleted. ## Reference - [Public data and formats](https://arettic.com/docs/public-data.md): The no-key endpoints, the Markdown and JSON copy of every page, the discovery files, and the licence. - [Rate limits and quotas](https://arettic.com/docs/rate-limits.md): Every limit, what it is keyed on, the headers that report it, and how to back off. - [Error codes](https://arettic.com/docs/errors.md): every API error code, what it means and whether to retry. - [JSON Schemas](https://arettic.com/schemas.md): receipts, execute requests and responses, webhook events, pass rules. - [Changelog](https://arettic.com/changelog.md): what changed, newest first (RSS: https://arettic.com/changelog.xml). ## Task types - `find_email`: Outbound prospecting, recruiting outreach, partner sourcing. Example input: `{"first_name":"Emily","last_name":"Carter","domain":"acme.com"}` - `verify_email`: Cleaning a list before a campaign, sign-up and CRM hygiene. Example input: `{"email":"emily@acme.com"}` - `enrich_company`: Account research, lead scoring and routing, CRM enrichment. Example input: `{"domain":"acme.com"}` - `enrich_person`: Lead qualification, contact research, candidate and investor research. Example input: `{"first_name":"Jason","last_name":"Miller","company_domain":"acme.com"}` - `web_search`: Market and competitor research, news monitoring, building target lists. Example input: `{"query":"Series A fintech startups in New York","n":5}` - `extract_url`: Reading pricing pages, docs, job posts and filings into an agent. Example input: `{"url":"https://acme.com/pricing"}` Public list: GET https://api.arettic.com/v1/task-types (no key). OpenAPI: https://arettic.com/openapi.json · MCP card: https://arettic.com/.well-known/mcp.json · Index for models: https://arettic.com/llms.txt · Everything in one file: https://arettic.com/llms-full.txt This page as HTML: https://arettic.com/docs · Markdown: https://arettic.com/docs.md · JSON: https://arettic.com/docs.json --- # Quickstart From a key to a receipt: recommend a tool, execute it, read the result, and see what you were charged. This page takes you from nothing to a receipt. You get a key, ask which tool works for a task, buy one result, read the answer, and then do the same with a live key. Every step shows curl, the TypeScript SDK and the Python SDK, and the MCP tool where there is one. Start with a test key: it buys from mock tools, so nothing is charged while you build. > Arettic is pre-launch. Keys go to design partners first. Everyone else joins the waitlist at [/waitlist](/waitlist) and gets an invite when self-serve sign-up opens: a key with $1 of credits, test mode with mock providers, and the MCP server. The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. The curl examples work as written for anyone who has a key. ## 1. Get a key 1. Sign in at [/app](/app) with your work email. You get a one-time link by email. It works once and expires in 15 minutes. The same link creates your account if you're new. 2. Create an org. Give it a name of 2 to 100 characters. Creating the org gives it $1 of trial credits (1,000 credits) that last 30 days. 3. Open Agents and add an agent. Pick a name, the mode **Test** (mock tools, no credits move), a monthly budget (default $50) and when to ask you before a purchase (default: any single purchase over $20). You can't change the mode later, so make a second agent for live when you get there. 4. Copy the key. It starts with `sk_test_` and is shown once, on the same page, in a box that disappears after 2 minutes. Arettic keeps only a SHA-256 hash of it, so it can't show it again. Lost it? Rotate the key on the same page; the old one stops at once. Put the key in the `ARETTIC_API_KEY` environment variable. Both SDKs and the MCP bridge read it. Every request to the API sends it as `Authorization: Bearer sk_test_…`. **Shell** ```bash export ARETTIC_API_KEY=sk_test_… # ARETTIC_API_URL is optional. The default is https://api.arettic.com. ``` **Install the SDKs (published at launch)** ```bash npm install @arettic/sdk # TypeScript. No dependencies; needs Node 18+ or any runtime with fetch. pip install arettic # Python 3.9+. Standard library only. ``` Doing this from code instead of the dashboard? A signed-in session or an org key can create agents with `POST /v1/orgs/{orgId}/agents`; the answer carries the key once. See [Authentication and keys](/docs/authentication) and the [org API](/docs/org-api). ## 2. Ask which tool works Send a task type. Arettic ranks every tool that can do it by score, then by price when scores are within 2 points. Whether a tool can be bought through Arettic never changes its rank, so pick the first option with `purchasable: true`. The call is free; it counts against your plan's daily score lookups (1,000 a day on pay as you go). With a test key the options are mock tools, and their ids start with `mock-`. With a live key they're real tools. **curl** ```bash curl https://api.arettic.com/v1/recommend \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "task_type": "verify_email", "region": "US" }' ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; // Reads ARETTIC_API_KEY (and ARETTIC_API_URL) from the environment when you leave them out. const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); 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"); console.log(tool.tool_id, tool.price_per_success); // "mock-verify-email" with a test key ``` **Python** ```python from arettic import Arettic client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) rec = client.recommend(task_type="verify_email", region="US") tool_id = next(o["tool_id"] for o in rec["options"] if o["purchasable"]) print(tool_id) # "mock-verify-email" with a test key ``` From an MCP client, connect once and the model calls the `recommend` tool itself. With Claude Code, add the stdio bridge (published at launch). Any client that speaks Streamable HTTP can instead connect to `https://api.arettic.com/mcp` with the same `Authorization: Bearer` header. Claude Desktop and Cursor configs are on [MCP server](/docs/mcp). **Claude Code** ```bash claude mcp add arettic --env ARETTIC_API_KEY=sk_test_… -- npx -y @arettic/mcp ``` **MCP tool call** ```json { "name": "recommend", "arguments": { "task_type": "verify_email", "region": "US" } } ``` The MCP `recommend` tool takes `task_type` or a plain-language `task`, plus `region`, `max_price`, `min_score` and `sort`, all as top-level arguments. Over REST, `max_price` and `min_score` go inside `constraints`. **Response (example)** ```json { "task_type": "verify_email", "region": "US", "sort": "score", "test_mode": true, "formula_version": "v1", "ranking": "score, then price when scores are within 2 points; purchasability never changes rank", "options": [ { "tool_id": "mock-verify-email", "name": "Mock Email Verifier", "provider": "Arettic Mock Provider", "score": 62.87, "score_week": "2026-09-28", "score_inputs": { "A": 0.5958, "S": 0.5958, "P": 0.5958, "R": 0.7225, "L": 1, "D": 0 }, "sample_size": 10, "price_per_success": { "credits": "6", "usd": "0.006" }, "success_rate": 0.5958, "p50_latency_ms": 5, "pass_rule": "verify_email@v1", "purchasable": true, "regions": ["GLOBAL", "US"] } ] } ``` Each option carries its `score` (0 to 100), the six score inputs, `price_per_success` in credits and USD (1 credit = $0.001), the `pass_rule` its results are checked against, and `purchasable`. Send a plain-language `task` instead of `task_type` and the answer says which type it chose in `mapped_from_task`. Every field, sort and constraint is on [Recommend](/docs/recommend). ## 3. Buy one result Send the `tool_id` and one `input`. The input fields come from the task type: for `verify_email` it's `{ "email": "…" }`. With a test key the mock provider answers and the real pass rule runs on its answer. No credits move. **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-verify-email", "input": { "email": "jason@acme.com" } }' ``` **TypeScript** ```ts const bought = await arettic.execute({ tool_id: tool.tool_id, input: { email: "jason@acme.com" }, }); if (bought.status === "passed" || bought.status === "partial") { console.log(bought.result, bought.charged); // charged is 0 in test mode } else if (bought.status === "failed") { console.log("Not charged:", bought.reason); } ``` **Python** ```python res = client.execute({"tool_id": tool_id, "input": {"email": "jason@acme.com"}}) print(res["status"], res.get("result"), res.get("reason")) print(res["would_have_charged"]) # test mode: what a live key would have paid ``` **MCP tool call** ```json { "name": "execute", "arguments": { "tool_id": "mock-verify-email", "input": { "email": "jason@acme.com" } } } ``` **Response (example)** ```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" } } ``` The SDKs add an `idempotency_key` to every execute call, so a retry after a dropped connection can never buy twice. From curl, send your own (up to 200 characters) once you go live. `execute` also takes `inputs` (a list of up to 1,000), `max_price` (the most you'll pay for the whole request, in credits), `approval_id` and `fallback`. All of it is on [Execute](/docs/execute). ## 4. Read the response Look at `status` first. Execute statuses | Status | What happened | What you get | |---|---|---| | passed | The result passed the published check for its task type. | `result`, and `charged` (the price). In test mode `charged` is 0 and `would_have_charged` shows the price. | | failed | The result didn't pass the check, or the provider errored. | `reason` only. The data is withheld and nothing is charged. In test mode `would_have_charged` is 0. | | partial | Web search only: fewer results than asked for. | `result` and `reason` (for example `results:2/5`). Charged pro rata. | | completed | A batch of 2 to 25 `inputs` finished. | `summary` (items, passed, partial, failed) and `items[]`, one entry per input with its own status. | | queued | Over 25 inputs with a live key: the request runs as a job (HTTP 202). Test keys run every batch inline. | `job_id`. Poll `GET /v1/jobs/{job_id}`, or use `waitForJob` / `wait_for_job` in the SDKs. See [Batches and jobs](/docs/jobs). | | approval_required | A live purchase over the agent's approval threshold or its monthly budget (HTTP 202). An owner has been emailed. | `approval_id` and `expires_at` (24 hours). Poll `GET /v1/approvals/{approval_id}`, then send the same request again with `approval_id`. See [Budgets and approvals](/docs/budgets-and-approvals). | The fields of a single-input answer: Execute response fields | Field | Meaning | |---|---| | test_mode | `true` on answers to a test key. Absent on live answers. | | tool_id, task_type | The tool that ran and its task type. | | check_version | The version of the pass rule that judged the result (`v1`). It's on the receipt too. | | charged | What was taken, as credits and USD: `{ "credits": "6", "usd": "0.006" }`. Always 0 in test mode. | | would_have_charged | Test mode only: what a live key would have paid for this answer. | | result | The provider's answer. Present on `passed` and `partial` only. | | reason | Why it failed or was partial: for example `status:unknown`, `no_result`, `provider_error`, `timeout`, `blocked_page`. | | execution_id, receipt_id, refunded | Live only: this item's id, the receipt for the whole request, and the credits that were held but not taken. | Try the fail path now. The mock verifier fails any address that starts with `unknown`. An address that starts with `bad` comes back `invalid`, which is a definitive answer, so it passes and would be charged. Any input that contains `provider-error` gives `provider_error`, and `nomatch` gives `no_result`. The inputs for every mock tool are on [Test mode](/docs/test-mode). **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-verify-email", "input": { "email": "unknown@acme.com" } }' ``` **Response (example)** ```json { "test_mode": true, "tool_id": "mock-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "failed", "reason": "status:unknown", "would_have_charged": { "credits": "0", "usd": "0.000" } } ``` Anything the API refuses comes back as an error with one shape, and the SDKs raise it as `AretticApiError`. `invalid_input` (HTTP 400) is free: the input is checked before any provider is called, and `message` names the field. `unknown_tool` (404) means the id isn't in the catalog your key can see; a live key sees no `mock-` tools. `unauthenticated` (401) means the key is missing, wrong or revoked. Every code is on [Error codes](/docs/errors). `doc_url` links to the code's entry and `retryable` says whether sending the same request again can work. **Response (example)** ```json { "error": { "code": "invalid_input", "message": "input.email: must be an email address. Nothing was charged.", "doc_url": "https://arettic.com/docs/errors#invalid_input", "retryable": false } } ``` ### Any task, the same call Email is only the first example. Every task type works the same way: pick the tool from `recommend`, send its `input`, and pay only if the result passes. With a test key, these mock tools answer each task type: Every task type, its test tool and an example input | Task type | Used for | Test tool | Example input | |---|---|---|---| | `find_email` | Outbound prospecting, recruiting outreach, partner sourcing | `mock-find-email` | `{"first_name":"Emily","last_name":"Carter","domain":"acme.com"}` | | `verify_email` | Cleaning a list before a campaign, sign-up and CRM hygiene | `mock-verify-email` | `{"email":"emily@acme.com"}` | | `enrich_company` | Account research, lead scoring and routing, CRM enrichment | `mock-enrich-company` | `{"domain":"acme.com"}` | | `enrich_person` | Lead qualification, contact research, candidate and investor research | `mock-enrich-person` | `{"first_name":"Jason","last_name":"Miller","company_domain":"acme.com"}` | | `web_search` | Market and competitor research, news monitoring, building target lists | `mock-web-search` | `{"query":"Series A fintech startups in New York","n":5}` | | `extract_url` | Reading pricing pages, docs, job posts and filings into an agent | `mock-extract-url` | `{"url":"https://acme.com/pricing"}` | ## 5. Switch to a live key Add a second agent on the Agents page and pick the mode **Live**. Its key starts with `sk_live_`. Change nothing else: the same code and the same endpoints. Three things are different. - `recommend` returns real tools with real prices. A live key can't see `mock-` tools; asking for one answers `unknown_tool`. - `execute` holds the price in credits before the call, calls the provider with Arettic's own credentials, runs the check, and settles. A pass captures the hold. A fail releases it. The answer adds `execution_id`, `receipt_id` and `refunded`, and `charged` is real. - Your org needs credits. The $1 of trial credits from sign-up is spent first. An org that has never topped up can spend at most 50 credits an hour. Top up on the Billing page; the first top-up is $20. See [Credits, top-ups and plans](/docs/credits). Send an `idempotency_key` with every live purchase. If the connection drops and you send the same request again with the same key, you get the first answer back with `replayed: true`, never a second charge. The SDKs make a key for each call; pass your own to keep retries safe across restarts of your program. **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "hunter-verify-email", "input": { "email": "jason@acme.com" }, "idempotency_key": "quickstart-1" }' ``` **TypeScript** ```ts const live = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); // an sk_live_… key const bought = await live.execute( { tool_id: "hunter-verify-email", input: { email: "jason@acme.com" } }, { idempotencyKey: "quickstart-1" }, ); // Live answers carry a receipt_id: fetch the receipt with the same key. if ("receipt_id" in bought) { const receipt = await live.receipt(bought.receipt_id); console.log(receipt.charged, receipt.items[0]?.outcome); // { credits: "7", usd: "0.007" } "captured" } ``` **Python** ```python live = Arettic(api_key="sk_live_…") res = live.execute( {"tool_id": "hunter-verify-email", "input": {"email": "jason@acme.com"}}, idempotency_key="quickstart-1", ) print(res["status"], res["charged"]["credits"]) # Live answers carry a receipt_id: fetch the receipt with the same key. receipt = live.receipt(res["receipt_id"]) for item in receipt["items"]: print(item["provider"], item["outcome"], item["charged"]["credits"]) ``` **Response (example)** ```json { "status": "passed", "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "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" } } ``` Use the `tool_id` your live `recommend` returned; `hunter-verify-email` and its 7-credit price are an example. On a fail the answer has `reason`, `charged` is 0 and `refunded` is the full price: the hold went back to your balance. ### Fetch the receipt Every live purchase writes one immutable receipt: what was asked (as a hash), which provider answered, the check and its version, and, per item, what was charged and refunded. Fetch it by id with the same key. Any agent of the org can read the org's receipts, and they're on the dashboard under Receipts. **curl** ```bash curl https://api.arettic.com/v1/receipts/67a1fb46-c554-4cf6-b4e2-df1dbdf0e936 \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` **MCP tool call** ```json { "name": "get_receipt", "arguments": { "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936" } } ``` **Response (example)** ```json { "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "created_at": "2026-09-29T18:04:11.512Z", "tool_id": "hunter-verify-email", "job_id": null, "idempotency_key": "quickstart-1", "request_hash": "23350b3dcc6226289d9d6f4bdb8ccf7d7258c28317131242e6e566de6c8db75d", "check_version": "v1", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "summary": { "items": 1, "passed": 1, "partial": 0, "failed": 0 }, "items": [ { "index": 0, "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "captured", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "pricing_mode": "per_success", "input_hash": "ab5c971d93369517129c982ea1dc700e48f8c9f851aa78deef918c40d9af3828", "result_hash": "4e2efddb74f95b6ac46553aab5d2a016e3f3877ceeffa14f4eb2bae0be6ab637", "check_version": "v1", "settled_at": "2026-09-29T18:04:11.498Z" } ] } ``` `outcome` is the ledger state of the item: `captured` means charged, `released` means not charged. `input_hash` and `result_hash` are SHA-256 of the canonical JSON (keys sorted, no spaces), so you can check a result you stored against its receipt. Test keys write no receipts. The receipt field by field, the list endpoint and the CSV export are on [Receipts](/docs/receipts). Think a charged result is wrong? Dispute it within 7 days: [Disputes](/docs/disputes). To see what's left, call `GET /v1/balance` (`balance()` in both SDKs, `get_balance` over MCP). It returns the org's credits, paid and trial, plus this agent's month-to-date spend, its budget and what remains of it. **Response (example)** ```json { "org_balance": { "paid": { "credits": "0", "usd": "0.000" }, "trial": { "credits": "1000", "usd": "1.000" }, "total": { "credits": "1000", "usd": "1.000" } }, "agent_spent_month": { "credits": "0", "usd": "0.000" }, "agent_budget": { "credits": "50000", "usd": "50.000" }, "agent_budget_left": { "credits": "50000", "usd": "50.000" } } ``` ## Where next - [Test mode](/docs/test-mode): every mock tool and the inputs that make it pass, fail or go partial. - [Execute](/docs/execute): every request field, batches, `max_price`, fallback, idempotency, and each status with its fields. - [Receipts](/docs/receipts): the receipt field by field, listing and filtering, and the CSV export. - [Budgets and approvals](/docs/budgets-and-approvals): the monthly budget, the approval threshold, and what your agent does while an owner decides. - [MCP server](/docs/mcp): the hosted endpoint, the stdio bridge, client configs and all nine tools. - [SDKs](/docs/sdks): every method of the TypeScript and Python clients, errors and retries. - [Error codes](/docs/errors) and the [JSON Schemas](/schemas) for requests, responses and receipts. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/quickstart · Markdown: https://arettic.com/docs/quickstart.md · JSON: https://arettic.com/docs/quickstart.json --- # Authentication and keys Agent keys, org keys and sessions: what each can do, how to send them, and how to rotate or revoke them. Every request outside the public data carries one credential in the `Authorization` header. There are three kinds. An **agent key** lets your agent ask for a recommendation, buy a result and read its receipts. An **org key** lets a script or an agent run an org the way an owner does in the dashboard. A **session** is a signed-in person, or a script acting as one. This page says what each can do, how to send it, and how to rotate or revoke it. > Arettic is pre-launch. Keys go to design partners; everyone else can [join the waitlist](/waitlist). The `@arettic/sdk` (npm), `arettic` (PyPI) and `@arettic/mcp` packages used in the samples are published at launch. ## The three credentials The three credentials, side by side | Starts with | Credential | Held by | Reaches | Made | |---|---|---|---|---| | sk_test_… or sk_live_… | Agent key | One agent | The buying endpoints: recommend, execute, jobs, receipts, disputes, approvals, balance, `GET /v1/agent` and the MCP server | On the Agents page, or with `POST /v1/orgs/{orgId}/agents` | | ok_… | Org key | A script or agent that runs the org | Only `/v1/orgs/{orgId}/…` for its own org. A `read` key only reads. Never key management or a data-deletion request | On the Team page by a signed-in owner, or with `POST /v1/orgs/{orgId}/keys` | | ss_… or the arettic_session cookie | Session | A signed-in person, or a script acting as one | Every account and org endpoint: `/v1/me`, `POST /v1/orgs`, `/v1/orgs/{orgId}/…`, `POST /auth/logout` | `POST /auth/email/start`, then the emailed link; or Google sign-in | Public data needs no credential: `GET /v1/tools`, `/v1/scores`, `/v1/task-types`, `/v1/pricing`, `/v1/formula`, `/v1/reports` and `/v1/status`. See [public data](/docs/public-data). ## Send the header Send the credential as a bearer token. The header is the same for all three kinds. **Header** ```http Authorization: Bearer sk_test_… ``` **curl** ```bash curl https://api.arettic.com/v1/agent \ -H "Authorization: Bearer sk_test_…" ``` Browsers use the `arettic_session` cookie instead. The API sets it when you open a sign-in link and reads it when the `Authorization` header carries no session token or org key. Scripts and agents send the header and can ignore the cookie. The SDKs and the MCP bridge read the key from the `ARETTIC_API_KEY` environment variable, and the API address from `ARETTIC_API_URL` (default `https://api.arettic.com`). A key never has to sit in code. ## Agent keys Each agent has one key, and the key names the agent. The agent carries the mode, the monthly budget, the approval threshold and the IP allowlist, so the key carries them too. A test agent's key starts with `sk_test_`: it buys from mock tools and no credits move. A live agent's key starts with `sk_live_`: it buys real results with the org's credits and writes receipts. The mode is fixed when the agent is made; for the other mode, make another agent. [Test mode](/docs/test-mode) says what a test key returns. ### Get a key 1. Sign in and open [Agents](/app/agents). Add an agent: a name (up to 80 characters), test or live, a monthly budget in dollars, and when to ask an owner before a single purchase. 2. The key appears once, on that page, for 2 minutes. Copy it now. We keep only a SHA-256 hash, so we can't show it again. 3. Give it to your agent as `ARETTIC_API_KEY`. A script can do the same with a session token or a write org key. Any member of the org can add an agent. Budgets and thresholds in the API are whole credits (1 credit = $0.001): `monthly_budget_credits` defaults to 50000 ($50 a calendar month, UTC) and `approval_threshold_credits` to 20000 ($20), with `null` meaning never ask. `mode` defaults to `test`. At the plan's limit on active agents the API answers 402 `plan_limit`. **curl** ```bash curl -X POST https://api.arettic.com/v1/orgs/$ORG_ID/agents \ -H "Authorization: Bearer ok_…" \ -H "Content-Type: application/json" \ -d '{"name": "Research agent", "mode": "test"}' ``` **TypeScript** ```ts import { AretticOrg } from "@arettic/sdk"; const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, // an ok_… key orgId: process.env.ARETTIC_ORG_ID!, }); const { agent, key } = await org.agents.create({ name: "Research agent", mode: "test" }); console.log(agent.id, key); // the key is in this answer only ``` **Python** ```python import os from arettic import AretticOrg org = AretticOrg(api_key=os.environ["ARETTIC_ORG_KEY"], org_id=os.environ["ARETTIC_ORG_ID"]) made = org.agents.create(name="Research agent", mode="test") print(made["agent"]["id"], made["key"]) # the key is in this answer only ``` **Response (example)** ```json { "agent": { "id": "2f6c1c0e-5b1a-4d8e-9a3b-7c1d2e3f4a5b", "name": "Research agent", "mode": "test", "status": "active", "monthly_budget": { "credits": "50000", "usd": "50.000" }, "approval_threshold": { "credits": "20000", "usd": "20.000" }, "ip_allowlist": [], "key_prefix": "sk_test_Ab12", "key_last_used_at": null, "created_at": "2026-09-28T14:02:11.000Z" }, "key": "sk_test_…", "key_note": "Shown once. Store it now; we keep only a hash." } ``` ### What an agent key reaches Endpoints that take an agent key | Endpoint | What it does | |---|---| | POST /v1/recommend | Ranked tools for a task. See [recommend](/docs/recommend). | | POST /v1/execute | Run a tool; charged only if the result passes its check. See [execute](/docs/execute). | | GET /v1/jobs/{id} | A batch of more than 25 items: status, progress and per-item outcomes. See [jobs](/docs/jobs). | | GET /v1/receipts | The org's receipts, newest first. See [receipts](/docs/receipts). | | GET /v1/receipts/{id} | One receipt: each item's provider, cost, result hash, check version, outcome and refund. | | POST /v1/disputes | Dispute a charged item within 7 days. See [disputes](/docs/disputes). | | GET /v1/disputes/{id} | A dispute's status, decision and refund. | | GET /v1/approvals/{id} | An approval request's status, locked tool and amount, and expiry. See [budgets and approvals](/docs/budgets-and-approvals). | | GET /v1/balance | The agent's month-to-date spend, its budget and the org's credits. | | GET /v1/agent | The calling agent and its limits. | | POST /mcp | The MCP server over Streamable HTTP. See [MCP](/docs/mcp). | An agent key can't call the account or org endpoints (`/v1/me`, `/v1/orgs/{orgId}/…`). They answer 401 `unauthenticated`. Use an org key or a session there. ### Check a key `GET /v1/agent` answers with the agent the key belongs to and its limits. It is the quickest way to confirm that a key works, and to see which agent, mode and budget a key carries. **curl** ```bash curl https://api.arettic.com/v1/agent \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); const agent = await arettic.me(); console.log(agent.name, agent.mode, agent.monthly_budget.usd); ``` **Python** ```python from arettic import Arettic client = Arettic() # reads ARETTIC_API_KEY agent = client.me()["agent"] print(agent["name"], agent["mode"], agent["monthly_budget"]["usd"]) ``` **Response (example)** ```json { "agent": { "id": "2f6c1c0e-5b1a-4d8e-9a3b-7c1d2e3f4a5b", "name": "Research agent", "mode": "test", "status": "active", "monthly_budget": { "credits": "50000", "usd": "50.000" }, "approval_threshold": { "credits": "20000", "usd": "20.000" }, "ip_allowlist": [], "key_prefix": "sk_test_Ab12", "key_last_used_at": "2026-09-29T09:41:00.000Z", "created_at": "2026-09-28T14:02:11.000Z" } } ``` ### Use the key with MCP The MCP server takes the same key. A client that speaks Streamable HTTP connects to `https://api.arettic.com/mcp` with `Authorization: Bearer sk_…`. Other clients run the `@arettic/mcp` bridge, which reads the key from `ARETTIC_API_KEY` and never prints it. Org keys don't work here. See [MCP](/docs/mcp) for the tools and the client setups. **MCP config** ```json { "mcpServers": { "arettic": { "command": "npx", "args": ["-y", "@arettic/mcp"], "env": { "ARETTIC_API_KEY": "sk_test_…" } } } } ``` ### Rate limit An agent key can make 600 requests a minute, counted across every endpoint above and `/mcp`. Every answer to a request with a valid key carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window resets). Over the limit the API answers 429 `rate_limited` with a `Retry-After` header. The SDKs wait and retry reads by themselves. The other limits are on [rate limits](/docs/rate-limits). ## Org keys An org key lets a script or an agent do what a person does in the dashboard, through the API: add agents, rotate their keys, read receipts, decide approvals, add webhooks and more. The full list of endpoints is on [org API](/docs/org-api). This section covers what the key is and what it can't do. - It starts with `ok_` and has a scope. A `read` key can only send `GET` requests. A `write` key can do anything its maker can. - It acts as the person who made it, with that person's current role. Every role check that applies to them applies to the key. - It works only on its own org's endpoints, `/v1/orgs/{orgId}/…`. Anywhere else it answers 403 `forbidden`, including the agent endpoints and `/v1/me`. - It can't manage keys. `/v1/orgs/{orgId}/keys` needs a signed-in owner, so a leaked key can't make itself a successor. - It can read data-deletion requests but not make one. `POST /v1/orgs/{orgId}/deletion-requests` needs a signed-in owner. - It stops working the moment its maker leaves the org or is removed, and when an owner revokes it. - An org can have up to 20 keys. Each key can make 300 requests a minute, with the same `RateLimit-*` headers as agent keys. ### Make an org key Only an owner can make or revoke org keys, and only while signed in: on [Team](/app/team) under Org API keys (a name, then read or write), or with a session token. A member's session answers 403 `forbidden`. **curl** ```bash curl -X POST https://api.arettic.com/v1/orgs/$ORG_ID/keys \ -H "Authorization: Bearer ss_…" \ -H "Content-Type: application/json" \ -d '{"name": "Billing agent", "scope": "read"}' ``` **Response (example)** ```json { "key": { "id": "5b7d9f1a-2c3e-4d5f-8a9b-0c1d2e3f4a5b", "name": "Billing agent", "scope": "read", "prefix": "ok_Ab12Cd3", "created_by_email": "emily@example.com", "created_at": "2026-09-29T10:20:00.000Z", "last_used_at": null }, "secret": "ok_…", "note": "Shown once. Store it now; we keep only a hash." } ``` Any member can list the org's keys with `GET /v1/orgs/{orgId}/keys` while signed in. The list shows each key's name, scope, prefix, maker and last use, never the key itself. **Response (example)** ```json { "keys": [ { "id": "5b7d9f1a-2c3e-4d5f-8a9b-0c1d2e3f4a5b", "name": "Billing agent", "scope": "read", "prefix": "ok_Ab12Cd3", "created_by_email": "emily@example.com", "created_at": "2026-09-29T10:20:00.000Z", "last_used_at": "2026-09-29T11:02:00.000Z" } ] } ``` ### Use an org key Send it as a bearer token on any `/v1/orgs/{orgId}/…` endpoint of its org. The `AretticOrg` clients take the key and the org id. **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/agents \ -H "Authorization: Bearer ok_…" ``` **TypeScript** ```ts import { AretticOrg } from "@arettic/sdk"; const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, orgId: process.env.ARETTIC_ORG_ID!, }); const agents = await org.agents.list(); console.log(agents.map((a) => [a.name, a.mode, a.key_prefix])); ``` **Python** ```python import os from arettic import AretticOrg org = AretticOrg(api_key=os.environ["ARETTIC_ORG_KEY"], org_id=os.environ["ARETTIC_ORG_ID"]) for a in org.agents.list()["agents"]: print(a["name"], a["mode"], a["key_prefix"]) ``` With a `read` key, every method that writes answers 403 `forbidden`. The `AretticOrg` clients leave out key management on purpose: making and revoking keys needs a person. ## Sessions A session is how a person uses the dashboard, and how a script acts as a person. Some things need one: creating an org, making or revoking org keys, and asking for data deletion. Sign-in is by email link. Google sign-in works where it is configured. Customer accounts have no passwords. ### Sign in by email 1. `POST /auth/email/start` with the address. The API answers 202 `{"status": "sent"}` and emails a one-time link. A new address becomes an account when its link is first used: the link is the proof that the email is real. 2. The link points at `GET /auth/email/verify?token=…`. It works once and expires after 15 minutes. Opened in a browser, it sets the `arettic_session` cookie and sends you to the dashboard. 3. For a script, take the `token` from the link and send it to `POST /auth/email/verify`. The answer holds `session_token` (`ss_…`), `user_id` and `new_user`. Send the token as a bearer token from then on. 4. A session lasts 30 days. `POST /auth/logout` ends it early. **curl** ```bash curl -X POST https://api.arettic.com/auth/email/start \ -H "Content-Type: application/json" \ -d '{"email": "emily@example.com"}' ``` **Response (example)** ```json { "status": "sent" } ``` **curl** ```bash # The token is the token= part of the link in the email. curl -X POST https://api.arettic.com/auth/email/verify \ -H "Content-Type: application/json" \ -d '{"token": "…"}' ``` **Response (example)** ```json { "session_token": "ss_…", "user_id": "0d9e8f7a-6b5c-4d3e-9f1a-0b9c8d7e6f5a", "new_user": false } ``` **curl** ```bash curl https://api.arettic.com/v1/me \ -H "Authorization: Bearer ss_…" ``` **Response (example)** ```json { "user": { "id": "0d9e8f7a-6b5c-4d3e-9f1a-0b9c8d7e6f5a", "email": "emily@example.com", "name": null, "email_verified": true, "phone": "+14155550132", "phone_verified": true }, "orgs": [ { "id": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "name": "Acme", "role": "owner" } ] } ``` **TypeScript** ```ts // No SDK for sessions: plain fetch. const api = "https://api.arettic.com"; await fetch(api + "/auth/email/start", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "emily@example.com" }), }); // Take the token from the link in the email, then: const verified = await fetch(api + "/auth/email/verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: process.env.ARETTIC_LOGIN_TOKEN }), }); const { session_token } = await verified.json(); const me = await fetch(api + "/v1/me", { headers: { authorization: "Bearer " + session_token }, }); console.log(await me.json()); ``` **Python** ```python # No SDK for sessions: urllib from the standard library. import json import os import urllib.request API = "https://api.arettic.com" def call(method, path, body=None, token=None): data = None if body is None else json.dumps(body).encode() headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = "Bearer " + token req = urllib.request.Request(API + path, data=data, method=method, headers=headers) with urllib.request.urlopen(req) as res: return json.load(res) call("POST", "/auth/email/start", {"email": "emily@example.com"}) # Take the token from the link in the email, then: session = call("POST", "/auth/email/verify", {"token": os.environ["ARETTIC_LOGIN_TOKEN"]}) me = call("GET", "/v1/me", token=session["session_token"]) print(me["user"]["email"], [o["name"] for o in me["orgs"]]) ``` Sign-in has two limits: 20 starts an hour per IP address, and 5 emails an hour per address. Over either, the API answers 429 `rate_limited`. A used, expired or unknown token answers 401 `invalid_token`; ask for a new link. ### Google sign-in Open `GET /auth/google/start` in a browser. It sends you to Google to pick an account, then back to `GET /auth/google/callback`, which sets the cookie and sends you to the dashboard. Accounts are matched by email, so an email-link account and a Google account with the same address are one account. Google sign-in is for browsers only; there is no token variant. - Google must have verified the account's email, or the sign-in answers 401 `unverified_email`. Use an email link instead. - The sign-in has to finish within 10 minutes, in the browser that started it, or it answers 400 `invalid_state`. Start again. - If Google doesn't complete the sign-in, the answer is 401 `google_failed`. Try again, or use an email link. - Where Google sign-in isn't switched on, `/auth/google/start` answers 503 `not_configured`. Use an email link. ### The session cookie The cookie is called `arettic_session`. It is `HttpOnly`, so page scripts can't read it; `SameSite=Lax`, so it travels with the redirect from the sign-in link; `Secure` in production; and it lasts 30 days. The API reads it only when the `Authorization` header carries no session token or org key. `POST /auth/logout` clears it. ## Rotate and revoke ### Agent keys Rotating issues a new key and revokes the old one in the same step. The old key is refused from that moment, so give the agent the new key straight away. Revoking stops the key without issuing a new one; the agent can't buy until you issue another. Any member of the org can do either. - Dashboard: [Agents](/app/agents), open **Key for** the agent, tick the confirmation, then **Rotate key** or **Revoke key**. The new key shows once, for 2 minutes. An agent with no key shows **Issue a key** instead. - API: `POST /v1/orgs/{orgId}/agents/{agentId}/rotate-key` and `POST /v1/orgs/{orgId}/agents/{agentId}/revoke-key`, with a session token or a write org key. `rotate-key` also issues a key for an agent that has none. **curl** ```bash curl -X POST https://api.arettic.com/v1/orgs/$ORG_ID/agents/$AGENT_ID/rotate-key \ -H "Authorization: Bearer ok_…" curl -X POST https://api.arettic.com/v1/orgs/$ORG_ID/agents/$AGENT_ID/revoke-key \ -H "Authorization: Bearer ok_…" ``` **TypeScript** ```ts const { key } = await org.agents.rotateKey(agentId); // the old key stopped working await org.agents.revokeKey(agentId); // { status: "revoked" } ``` **Python** ```python key = org.agents.rotate_key(agent_id)["key"] # the old key stopped working org.agents.revoke_key(agent_id) # {"status": "revoked"} ``` **Response (example)** ```json { "key": "sk_live_…", "key_note": "The previous key stopped working immediately." } ``` To pause an agent without touching its key, set its `status` to `disabled`: `PATCH /v1/orgs/{orgId}/agents/{agentId}` with `{"status": "disabled"}`, or **Edit** on the Agents page. Its key is refused with 401 `unauthenticated` until the agent is active again. If you think the key leaked, rotate it as well. ### Org keys Org keys don't rotate. Make a new key, move the caller to it, then revoke the old one. Revoking needs a signed-in owner: **Revoke** on the Team page, or `DELETE /v1/orgs/{orgId}/keys/{id}` with a session token. A revoked key answers 401 `unauthenticated` from then on; a key that is already revoked or unknown answers 404 `not_found`. **curl** ```bash curl -X DELETE https://api.arettic.com/v1/orgs/$ORG_ID/keys/$KEY_ID \ -H "Authorization: Bearer ss_…" ``` **Response (example)** ```json { "status": "revoked" } ``` ### Sessions `POST /auth/logout` with the session's token or cookie revokes it and clears the cookie. The answer is `{"status": "signed_out"}`. Sessions also end on their own after 30 days. **curl** ```bash curl -X POST https://api.arettic.com/auth/logout \ -H "Authorization: Bearer ss_…" ``` ## IP allowlist Each agent can carry an allowlist of IP addresses and CIDR ranges, IPv4 or IPv6. Empty, the default, means any address. With entries, a request with that agent's key from any other address is refused with 403 `ip_not_allowed`. The check runs as soon as the key is found, before the endpoint does anything, and it covers `/mcp` too. The address checked is the one the request arrives from. Behind NAT, a VPN or a cloud egress that is the shared public address, not the machine's own. Put the public address your agent calls from on the list, and use a range when it runs from a pool of addresses. - Dashboard: [Agents](/app/agents), **Edit** the agent, then the **IP allowlist** field, one address or range per line. Leave it empty to allow any address. - API: `PATCH /v1/orgs/{orgId}/agents/{agentId}` with `ip_allowlist`, with a session token or a write org key. Send `[]` to clear it. An entry that isn't an address or a range answers 400 `invalid_input`. **curl** ```bash curl -X PATCH https://api.arettic.com/v1/orgs/$ORG_ID/agents/$AGENT_ID \ -H "Authorization: Bearer ok_…" \ -H "Content-Type: application/json" \ -d '{"ip_allowlist": ["203.0.113.7", "203.0.113.0/24"]}' ``` **TypeScript** ```ts const agent = await org.agents.update(agentId, { ip_allowlist: ["203.0.113.7", "203.0.113.0/24"], }); console.log(agent.ip_allowlist); ``` **Python** ```python agent = org.agents.update(agent_id, ip_allowlist=["203.0.113.7", "203.0.113.0/24"]) print(agent["agent"]["ip_allowlist"]) ``` **Response (example)** ```json { "agent": { "id": "2f6c1c0e-5b1a-4d8e-9a3b-7c1d2e3f4a5b", "name": "Research agent", "mode": "live", "status": "active", "monthly_budget": { "credits": "50000", "usd": "50.000" }, "approval_threshold": { "credits": "20000", "usd": "20.000" }, "ip_allowlist": ["203.0.113.7", "203.0.113.0/24"], "key_prefix": "sk_live_Ab12", "key_last_used_at": "2026-09-29T09:41:00.000Z", "created_at": "2026-09-28T14:02:11.000Z" } } ``` **Response from another address (example)** ```json { "error": { "code": "ip_not_allowed", "message": "This key can't be used from this IP address", "doc_url": "https://arettic.com/docs/errors#ip_not_allowed", "retryable": false } } ``` Org keys and sessions have no allowlist. ## How keys are stored - We store a SHA-256 hash of every key and session token, never the value. A key is shown once, when it is made or rotated. If you lose it, rotate it. - We keep the first characters of each key so you can tell keys apart: `key_prefix` on an agent (12 characters) and `prefix` on an org key (10 characters). They appear in the dashboard and in `GET /v1/agent`. - We record when a key was last used (`key_last_used_at` on an agent, `last_used_at` on an org key; updated at most once a minute) and, for agent keys, the addresses it has been used from, so a key that turns up from a new place can be looked into. - A session records the address and user agent it was started from. Keep an agent key in `ARETTIC_API_KEY` on the machine that runs the agent, and an org key in a variable of your own. Don't put either in a repository, a browser or a URL. ## Errors Every error is `{ "error": { "code", "message", "doc_url", "retryable" } }`. Retry only when `retryable` is true. The codes the flows on this page can answer with: Error codes on this page | Code | HTTP | Meaning | Fix | |---|---|---|---| | [unauthenticated](/docs/errors#unauthenticated) | 401 | No valid key or session was sent, or the key was revoked. | Send `Authorization: Bearer ` with an active key. | | [forbidden](/docs/errors#forbidden) | 403 | You're signed in but your role can't do this. | Ask an org owner. | | [ip_not_allowed](/docs/errors#ip_not_allowed) | 403 | The agent key has an IP allowlist and this request came from elsewhere. | Call from an allowed IP or update the allowlist. | | [rate_limited](/docs/errors#rate_limited) | 429 (retry) | Too many requests in a short time. | Wait and retry. Limits are in the `RateLimit-*` headers. | | [invalid_token](/docs/errors#invalid_token) | 401 | A sign-in link or invite is invalid, used or expired. | Request a new link. | | [not_configured](/docs/errors#not_configured) | 503 | This feature isn't switched on in this environment. | Use another sign-in method. | | [unverified_email](/docs/errors#unverified_email) | 401 | Google hasn't verified this email address. | Sign in with an email link instead. | | [invalid_state](/docs/errors#invalid_state) | 400 | The Google sign-in took too long or was opened in another browser. | Start Google sign-in again. | | [google_failed](/docs/errors#google_failed) | 401 (retry) | Google didn't complete the sign-in. | Try again, or use an email link. | **Response (example)** ```json { "error": { "code": "unauthenticated", "message": "Invalid or revoked API key", "doc_url": "https://arettic.com/docs/errors#unauthenticated", "retryable": false } } ``` ### The wrong credential on an endpoint What each mismatch answers | You sent | To | Answer | |---|---|---| | nothing | an agent endpoint | 401 `unauthenticated`: Send your agent key as `Authorization: Bearer sk_…` | | nothing | an account or org endpoint | 401 `unauthenticated`: Sign in first | | an agent key | an account or org endpoint | 401 `unauthenticated`: Sign in first | | a session token | an agent endpoint | 401 `unauthenticated`: Invalid or revoked API key | | a revoked key, or the key of a disabled agent | an agent endpoint | 401 `unauthenticated`: Invalid or revoked API key | | an agent key from an address outside its allowlist | an agent endpoint | 403 `ip_not_allowed` | | an org key | anything outside `/v1/orgs/{orgId}/…` for its org, including agent endpoints and `/v1/me` | 403 `forbidden` | | an org key | `/v1/orgs/{orgId}/keys…`, or `POST /v1/orgs/{orgId}/deletion-requests` | 403 `forbidden` | | a read org key | a `POST`, `PUT`, `PATCH` or `DELETE` | 403 `forbidden`: This org key is read-only | | an unknown or revoked org key | anything | 401 `unauthenticated`: Unknown or revoked org key | | a member's session | an owner-only action: make or revoke org keys, invite, change roles | 403 `forbidden`: Only an org owner can do this | ## Related - [Quickstart](/docs/quickstart): from a key to a receipt. - [Test mode](/docs/test-mode): what `sk_test_` keys return. - [Org API](/docs/org-api): every endpoint an org key can call. - [MCP](/docs/mcp): the hosted server and the bridge. - [SDKs](/docs/sdks): the TypeScript and Python clients. - [Rate limits](/docs/rate-limits): every limit and how to back off. - [Error codes](/docs/errors): the full registry. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/authentication · Markdown: https://arettic.com/docs/authentication.md · JSON: https://arettic.com/docs/authentication.json --- # Test mode Mock providers with fixed answers: build the pass and the fail path without spending a credit. Keys that start with `sk_test_` are test keys. A test key sends the same requests as a live key, with the same headers and the same JSON bodies, and gets responses of the same shape. The difference is what answers: a mock provider with fixed answers instead of a real one. No credits move, no real provider is called, and the real pass rule still decides whether the result passed. The mock providers are deterministic. The input decides the outcome, so your agent can build the pass path, the partial path and the fail path, and run each of them again and again with the same result. > Before launch, keys go to design partners. Everyone else can [join the waitlist](/waitlist). The `@arettic/sdk` and `arettic` (PyPI) packages and the `@arettic/mcp` bridge are published at launch; the samples on this page are written against their source. ## Get a test key Every agent has one mode, test or live, chosen when the agent is made. The mode can't be changed later; make a second agent for the other mode. The key is shown once. - In the dashboard: sign in at [/app](/app), open the [Agents page](/app/agents), add an agent and pick Test. The key appears on the page right after you add it. - From the org API: `POST /v1/orgs/{orgId}/agents` with a `name` and `mode: "test"` (`mode` defaults to `test`). The response carries the key in `key`, once, with a `key_note`. Org keys (`ok_…`) act as the owner who made them; see [authentication](/docs/authentication) and the [org API](/docs/org-api). **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/agents \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Test agent", "mode": "test" }' ``` Put the test key in the `ARETTIC_API_KEY` environment variable. Both SDKs and the MCP bridge read it, and every request sends it as `Authorization: Bearer sk_test_…`. The examples below assume that variable holds a test key. ## What a test key changes - `POST /v1/recommend` returns only mock tools, one per task type, and says so with `test_mode: true`. - `POST /v1/execute` runs the mock provider for the tool's task type, applies the real pass rule with the same `check_version` as live, and answers with `test_mode: true`, a `charged` of 0 and `would_have_charged`: the price a live call would have paid. - The input is validated before the call, the same way as live, except that domains are not looked up in DNS. Made-up domains like `example.com` or `acme.catchall.test` are fine. - A fail withholds the result, in test mode too. You get the `reason` and nothing else, which is exactly what a live fail looks like. - A live key can't reach a mock tool: it gets `404 unknown_tool`. A test key can name any tool id from the catalog, mock or real. The mock for that tool's task type answers, and `would_have_charged` uses that tool's price. ## The mock tools There is one mock tool per task type. Its id is `mock-` plus the task type with underscores turned into hyphens. The input fields are the published ones for the task type; a request missing one is rejected as `invalid_input` before anything runs. The mock tools and their input fields | Tool id | Task type | Input | |---|---|---| | mock-find-email | find_email | `first_name`: string; `last_name`: string; `domain`: company domain, e.g. acme.com | | mock-verify-email | verify_email | `email`: string | | mock-enrich-company | enrich_company | `domain`: company domain; `name`: company name (if no domain); `country`: optional, with name | | mock-enrich-person | enrich_person | `first_name`: string; `last_name`: string; `company_domain`: company domain | | mock-web-search | web_search | `query`: string, up to 500 characters; `n`: 1–25, default 5 | | mock-extract-url | extract_url | `url`: http(s) URL | List them without a key: `GET https://api.arettic.com/v1/tools?mode=test`. Each has a `price_per_success`, which is what `would_have_charged` reports on a pass, and `test_mode: true`. `GET https://api.arettic.com/v1/tools/mock-find-email` shows one in detail. Without `mode=test`, mock tools never appear. **curl** ```bash curl "https://api.arettic.com/v1/tools?mode=test" ``` **Response (example, one of the tools shown)** ```json { "tools": [ { "tool_id": "mock-find-email", "name": "Mock Email Finder", "provider": "Arettic Mock Provider", "task_type": "find_email", "regions": ["GLOBAL", "US"], "purchasable": true, "price_per_success": { "credits": "38", "usd": "0.038" }, "price_valid_from": null, "pass_rule": "find_email@v1", "scores": [{ "region": "GLOBAL", "score": 56.53, "week": "2026-09-28", "sample_size": 10 }], "test_mode": true } ], "license": { "id": "CC-BY-4.0", "url": "https://creativecommons.org/licenses/by/4.0/", "attribution": "Arettic (arettic.com)" }, "generated_at": "2026-09-29T18:00:00.180Z" } ``` ### What each mock returns on a pass Use these shapes in your assertions. Placeholders in angle brackets come from your input. The fixture each mock tool returns when the input has no fail trigger | Tool id | Result | |---|---| | mock-find-email | `{ "email": ".@", "verification_status": "valid" }`. Names are lowercased and stripped to letters. | | mock-verify-email | `{ "email": "", "status": "valid" }`, lowercased. | | mock-enrich-company | `{ "name": "", "domain": "", "employee_range": "51-200", "industry": "Software", "country": "" }`. `name` is your `name`, or the first label of the domain with a capital letter. `country` is your `country`, or `US`. | | mock-enrich-person | `{ "name": " ", "company_domain": "", "title": "Head of Marketing", "email": "@", "linkedin_url": "https://www.linkedin.com/in/-mock" }` | | mock-web-search | `{ "results": [ { "url": "https://example.test//", "title": "Result for ", "snippet": "Mock snippet ." } ] }` with `n` results (default 5). The URL uses the first 20 characters of the query, URL-encoded. | | mock-extract-url | `{ "url": "", "status_code": 200, "title": "Mock page", "content": "" }` | ## A full request and response Ask which tool to use, then run it. With a test key the first option for `find_email` is `mock-find-email`, and the response says `test_mode: true`. Everything else in that response is as described on the [recommend](/docs/recommend) page. **curl: recommend** ```bash curl https://api.arettic.com/v1/recommend \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "task_type": "find_email" }' ``` **curl: execute** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-find-email", "input": { "first_name": "Emily", "last_name": "Carter", "domain": "example.com" } }' ``` **Response (example)** ```json { "test_mode": true, "tool_id": "mock-find-email", "task_type": "find_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "passed", "result": { "email": "emily.carter@example.com", "verification_status": "valid" }, "would_have_charged": { "credits": "38", "usd": "0.038" } } ``` The same call with the SDKs. Both clients read `ARETTIC_API_KEY` (and `ARETTIC_API_URL`, if set) from the environment when you pass nothing. **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic(); // reads ARETTIC_API_KEY, here a test key const r = await arettic.execute({ tool_id: "mock-find-email", input: { first_name: "Emily", last_name: "Carter", domain: "example.com" }, }); if ("test_mode" in r && r.status === "passed") { console.log(r.result); // { email: "emily.carter@example.com", verification_status: "valid" } console.log(r.would_have_charged.credits); // "38" } ``` **Python** ```python from arettic import Arettic arettic = Arettic() # reads ARETTIC_API_KEY, here a test key r = arettic.execute( tool_id="mock-find-email", input={"first_name": "Emily", "last_name": "Carter", "domain": "example.com"}, ) print(r["status"]) # passed if r["status"] == "passed": print(r["result"]) # {'email': 'emily.carter@example.com', 'verification_status': 'valid'} print(r["would_have_charged"]["credits"]) # 38 ``` Over MCP, put the test key in your client's config (`ARETTIC_API_KEY=sk_test_…`; the client configs are on the [MCP](/docs/mcp) page) and call the `execute` tool with the same arguments. The tool result is the same JSON as above, as text content. `get_receipt` and `open_dispute` need a live key. **MCP tool call: execute** ```json { "name": "execute", "arguments": { "tool_id": "mock-find-email", "input": { "first_name": "Emily", "last_name": "Carter", "domain": "example.com" } } } ``` ## The response, field by field Fields of a test-mode execute response for one input | Field | Present | Meaning | |---|---|---| | test_mode | always | `true`. A live response never has this field. | | status | always | `passed`, `partial` or `failed`. | | tool_id | always | The tool's id (its slug), even if you sent its UUID. | | task_type | always | The task type the mock ran and the rule that judged it. | | check_version | always | The version of the pass rule that was applied, the same as live (`v1` today). | | charged | always | Always `{ "credits": "0", "usd": "0.000" }`. | | result | passed, partial | The mock's data. Withheld on a fail. | | reason | failed, partial | A reason code from the pass rule, such as `catch_all`, `field_missing:industry` or `results:2/5`, or `provider_error` when the mock simulated an outage. The codes are listed on the [pass rules](/docs/pass-rules) page. | | would_have_charged | always | The tool's `price_per_success` on a pass. The price times the fraction, rounded up to a whole credit, on a partial. 0 on a fail or a provider error. | | receipt_id, execution_id, refunded | never | Test calls write no receipt and hold no credits, so a live response's settlement fields are absent. | ## Make a mock fail on purpose Put these values in the input and the mock returns data that fails its check, or simulates an outage. The pass rule then reports the same reason it would report live. Anything not listed here passes. Inputs that make each mock tool fail, and what comes back | Tool id | Input | What comes back | |---|---|---| | any mock tool | any field contains `provider-error` | `failed`, reason `provider_error`, `would_have_charged` 0. The provider is simulated as down. | | any mock tool | any field contains `nomatch` | `failed`, reason `no_result`: the provider returned nothing. Two rules name the gap differently: `mock-verify-email` reports `status:unknown` and `mock-extract-url` reports `http:none`. | | mock-find-email | `domain` ends in `.catchall.test` | `failed`, reason `catch_all`. | | mock-verify-email | `email` starts with `unknown` | `failed`, reason `status:unknown`: the verifier could not decide. | | mock-verify-email | `email` starts with `bad` | `passed`, with `status: "invalid"` in the result. A definite invalid is a true answer and would be charged. | | mock-enrich-company | `domain` is `missing-fields.test` | `failed`, reason `field_missing:industry`. | | mock-enrich-person | `company_domain` contains `nocontact` | `failed`, reason `field_missing:contact`: a title but no email, phone or LinkedIn URL. | | mock-web-search | `query` contains `few results` | `partial`, reason `results:2/5`: 2 results instead of `n` (default 5). `would_have_charged` is the price times 2/5, rounded up. With `n` of 1 or 2 it passes. | | mock-extract-url | `url` contains `blocked` | `failed`, reason `blocked_page`: a captcha page came back. | A simulated provider error is a normal `200` response with `status: "failed"` and `reason: "provider_error"`, not an error envelope. Live, a provider that still fails after one retry is never charged either. **curl: a fail** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-find-email", "input": { "first_name": "Emily", "last_name": "Carter", "domain": "example.catchall.test" } }' ``` **Response (example)** ```json { "test_mode": true, "tool_id": "mock-find-email", "task_type": "find_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "failed", "reason": "catch_all", "would_have_charged": { "credits": "0", "usd": "0.000" } } ``` **curl: a partial** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-web-search", "input": { "query": "few results please" } }' ``` **Response (example)** ```json { "test_mode": true, "tool_id": "mock-web-search", "task_type": "web_search", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "partial", "reason": "results:2/5", "result": { "results": [ { "url": "https://example.test/few%20results%20please/1", "title": "Result 1 for few results please", "snippet": "Mock snippet 1." }, { "url": "https://example.test/few%20results%20please/2", "title": "Result 2 for few results please", "snippet": "Mock snippet 2." } ] }, "would_have_charged": { "credits": "4", "usd": "0.004" } } ``` ## Batches with a test key Send `inputs` instead of `input`: an array of up to 1,000 objects. With a test key every batch runs at once and returns per-item results, even over 25 items. No job is created, so the response is never `queued`, and `GET /v1/jobs/{id}` has nothing to show for a test key. Live, a batch over 25 items becomes a job; see [jobs](/docs/jobs). Each input is validated first. If any input is invalid the whole request is rejected with `invalid_input`, the same as live. **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "mock-verify-email", "inputs": [{ "email": "emily@example.com" }, { "email": "unknown@example.com" }] }' ``` **Response (example)** ```json { "test_mode": true, "status": "completed", "tool_id": "mock-verify-email", "charged": { "credits": "0", "usd": "0.000" }, "would_have_charged": { "credits": "6", "usd": "0.006" }, "summary": { "items": 2, "passed": 1, "partial": 0, "failed": 1 }, "items": [ { "index": 0, "status": "passed", "result": { "email": "emily@example.com", "status": "valid" }, "would_have_charged": { "credits": "6", "usd": "0.006" } }, { "index": 1, "status": "failed", "reason": "status:unknown", "would_have_charged": { "credits": "0", "usd": "0.000" } } ] } ``` `would_have_charged` at the top is the sum over the items. `summary` counts the items by status. Items carry `index`, `status`, `result` on a pass or partial, `reason` on a fail or partial, and their own `would_have_charged`. They carry no `charged` field, because nothing was charged. **TypeScript** ```ts const batch = await arettic.execute({ tool_id: "mock-verify-email", inputs: [{ email: "emily@example.com" }, { email: "unknown@example.com" }], }); if ("test_mode" in batch && batch.status === "completed") { console.log(batch.summary); // { items: 2, passed: 1, partial: 0, failed: 1 } for (const item of batch.items) console.log(item.index, item.status, item.reason); } ``` **Python** ```python batch = arettic.execute( tool_id="mock-verify-email", inputs=[{"email": "emily@example.com"}, {"email": "unknown@example.com"}], ) print(batch["summary"]) # {'items': 2, 'passed': 1, 'partial': 0, 'failed': 1} for item in batch["items"]: print(item["index"], item["status"], item.get("reason")) ``` ## What test mode does not do - No receipts. There is no `receipt_id`, `execution_id` or `refunded` in the response, and `GET /v1/receipts` lists live purchases only. - No disputes. `POST /v1/disputes` with a test key answers `400 invalid_input`: test-mode purchases are free, so there is nothing to dispute. - No jobs. Batches run inline, and `GET /v1/jobs/{id}` answers `404 not_found` for a test key. - No holds, budgets or approvals. Nothing is reserved, so the agent's monthly budget and approval threshold are not checked, and you never see `approval_required`, `insufficient_credits`, `over_budget`, `price_above_max` or `trial_limit`. `max_price`, `approval_id`, `fallback` and `idempotency_key` are accepted and ignored. - No replay. There is nothing to replay: a repeated request runs the mock again and gets the same answer, because the mock is deterministic. - No DNS check. Live, a domain that definitely does not exist is rejected before the call. Test mode checks only the shape of the input. - No real data. Every result is a fixture. Never treat a mock result as a fact about a real company or person. - Still on: the input check, the agent key's rate limit and IP allowlist, and the plan's daily lookup quota for `recommend`. `GET /v1/balance` works and shows the org's credits; a test key never changes them. See [rate limits](/docs/rate-limits). ## Errors you can get with a test key Error codes a test key can receive from execute | Code | HTTP | When | |---|---|---| | invalid_input | 400 | `tool_id` missing, `input` not an object, `inputs` empty or over 1,000, or a field fails the task type's check. The message names the field, for example `input.email: must be an email address. Nothing was charged.` See [invalid_input](/docs/errors#invalid_input). | | unknown_tool | 404 | No tool with that id. A live key naming a mock tool gets this too. See [unknown_tool](/docs/errors#unknown_tool). | | unauthenticated | 401 | No `Authorization: Bearer` header, or a revoked key. See [unauthenticated](/docs/errors#unauthenticated). | | ip_not_allowed | 403 | The agent has an IP allowlist and the call came from elsewhere. See [ip_not_allowed](/docs/errors#ip_not_allowed). | | rate_limited | 429 | Too many calls from this agent. Wait for `Retry-After`. See [rate_limited](/docs/errors#rate_limited). | Every error has the same envelope: `{ "error": { "code", "message", "doc_url", "retryable" } }`. `doc_url` points at the code's entry on the [error codes](/docs/errors) page. ## Mock tools on the scores page Mock tools have scores. They are published on [/scores](/scores) under the heading "Test mode (mock providers)" and at `GET https://api.arettic.com/v1/scores?mode=test`. They come from the same weekly scoring pipeline as live scores, from benchmark runs against mock test sets that ship with the platform. A few expected answers in those sets differ from what the mock returns, on purpose, so accuracy sits realistically below 100%. These scores prove the scoring pipeline end to end. They say nothing about any real provider. Mock tools never appear in the live catalog, the live scores, `GET /v1/pricing` or a live key's recommendations. How scores are computed is on the [scores](/docs/scores) page. ## Switch to a live key Change the key from `sk_test_…` to `sk_live_…` and keep the code. Then: - `recommend` returns real tools. Pick the first option with `purchasable: true`. - `execute` returns `receipt_id`, `execution_id`, `charged` and `refunded`, and no `test_mode` or `would_have_charged`. - Credits are held before the call and settled after the check: charged on a pass, pro rata on a partial, released on a fail. - Budgets, approvals, idempotency keys, `max_price` and `fallback` all take effect. - A batch over 25 items runs as a job. - Charged items can be disputed within 7 days. Next: [Quickstart](/docs/quickstart), [Execute](/docs/execute), [Receipts](/docs/receipts), [Budgets and approvals](/docs/budgets-and-approvals), [Jobs](/docs/jobs), [MCP](/docs/mcp), [SDKs](/docs/sdks). Updated 2026-09-29. This page as HTML: https://arettic.com/docs/test-mode · Markdown: https://arettic.com/docs/test-mode.md · JSON: https://arettic.com/docs/test-mode.json --- # MCP server Connect Claude, Cursor or any MCP client: the Streamable HTTP endpoint, the stdio bridge, and the nine tools. Arettic is an MCP server. Claude Desktop, Claude Code, Cursor or your own agent can ask which tool works for a task, buy the result, and read the receipt through nine tools. This page covers the two ways to connect, the client configs, every tool with its arguments, a typical session, test mode, errors, and the server card. > Before launch, agent keys go to design partners; everyone else can [join the waitlist](/waitlist). The `@arettic/mcp` npm package is published at launch. The hosted endpoint and the tools on this page are the ones the package will forward to. ## What you need An agent key. Test keys start with `sk_test_` and are free: they buy from mock tools and use no credits. Live keys start with `sk_live_` and buy real results with your org's credits. Org keys (`ok_…`) are for the org API and don't work here, because the MCP tools act as an agent. To get a key, sign in to the dashboard, create an org, and add an agent in test mode. The key is shown once. The [quickstart](/docs/quickstart) walks through it, and [Authentication and keys](/docs/authentication) explains the three kinds of credential. ## Two ways to connect The hosted server speaks Streamable HTTP at `https://api.arettic.com/mcp`. A client that supports Streamable HTTP with a custom header connects to it directly. A client that only runs local stdio servers uses the bridge, `@arettic/mcp`, a small Node program that forwards every `tools/list` and `tools/call` to the hosted server. Both paths give the same tools with the same names, descriptions and schemas, because the bridge defines none of its own. Pick the hosted endpoint when you can send a header; pick the bridge when your client wants a command to run. ## The hosted endpoint Send JSON-RPC messages as `POST https://api.arettic.com/mcp`. Three headers matter: - `Authorization: Bearer sk_test_…` (or `sk_live_…`). Without it the answer is HTTP 401 with the error code [unauthenticated](/docs/errors#unauthenticated). An org key is refused the same way. - `Content-Type: application/json`. - `Accept: application/json, text/event-stream`. The MCP transport requires both media types; without them the answer is HTTP 406. The server is stateless: it builds a fresh session for every POST. There is no `Mcp-Session-Id` header, no `initialize` handshake is needed before `tools/call`, and every request carries the key. Responses are plain JSON, never a stream. `GET /mcp` and `DELETE /mcp` answer HTTP 405 with `Allow: POST` and the error code [method_not_allowed](/docs/errors#method_not_allowed), because there are no server-initiated streams and no sessions to end. Each key can make 600 requests a minute to `/mcp`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`; over the limit you get HTTP 429 with `Retry-After`. See [Rate limits and quotas](/docs/rate-limits). **curl** ```bash curl https://api.arettic.com/mcp \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"recommend","arguments":{"task_type":"find_email"}}}' ``` **Response (example)** ```json { "result": { "content": [ { "type": "text", "text": "{\n \"task_type\": \"find_email\",\n \"region\": \"GLOBAL\",\n \"sort\": \"score\",\n \"test_mode\": true,\n …\n}" } ] }, "jsonrpc": "2.0", "id": 1 } ``` Every tool answers with one `text` content item, and that text is JSON: the same body the matching REST endpoint returns. Parse it. When the API refused the call, the result also carries `isError: true` and the text is the API's error body (see [Errors and retries](#errors)). `tools/list` returns the nine tools with their JSON Schemas. ## The stdio bridge `@arettic/mcp` runs as a child process of your MCP client. It reads your key from the `ARETTIC_API_KEY` environment variable, connects to the hosted server, and forwards every `tools/list` and `tools/call`. It needs Node 18 or newer. The package is published to npm at launch; until then the command below has nothing to download. **The command a client runs** ```bash ARETTIC_API_KEY=sk_test_… npx -y @arettic/mcp ``` Environment variables the bridge reads | Variable | Required | Default | What it does | |---|---|---|---| | ARETTIC_API_KEY | yes | | An agent key: `sk_test_…` or `sk_live_…`. | | ARETTIC_API_URL | no | `https://api.arettic.com` | The API host. The bridge talks to `/mcp` on it. | If the key is missing, is an org key, or the API refuses it, the bridge writes one line to stderr saying what to do and exits with code 1. It never prints the key. If the API is merely unreachable at startup, the bridge still starts and retries on the first call. All logging goes to stderr; stdout carries only MCP messages. ### Use the bridge from code The package exports `createStdioProxy`, so you can embed the bridge in your own process or serve it on another MCP transport. Options: `apiKey` (required), `baseUrl` (default `https://api.arettic.com`), `timeoutMs` (default 5 minutes per request), `log` (a function; default stderr) and `fetch`. **TypeScript** ```ts import { createStdioProxy } from "@arettic/mcp"; const proxy = createStdioProxy({ apiKey: process.env.ARETTIC_API_KEY! }); await proxy.start(); // serves on stdin/stdout; pass any MCP Transport to serve elsewhere ``` ## Client configs Each block below carries a placeholder key; paste your own. The stdio blocks need Node on the machine that runs the client. A test key is the safe default: swap in a live key when you're ready to spend credits. ### Claude Desktop Open Settings, then Developer, then Edit Config, and add this to `claude_desktop_config.json`. Claude Desktop starts the bridge for you. **claude_desktop_config.json** ```json { "mcpServers": { "arettic": { "command": "npx", "args": [ "-y", "@arettic/mcp" ], "env": { "ARETTIC_API_KEY": "sk_test_…" } } } } ``` ### Claude Code **Terminal** ```bash # The stdio bridge claude mcp add arettic --env ARETTIC_API_KEY=sk_test_… -- npx -y @arettic/mcp # Or the hosted endpoint directly claude mcp add --transport http arettic https://api.arettic.com/mcp --header "Authorization: Bearer sk_test_…" ``` Or commit a `.mcp.json` to your project with the same `mcpServers` block as the Claude Desktop config, so the whole team gets the server. ### Cursor Add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project). Either the bridge: **mcp.json (bridge)** ```json { "mcpServers": { "arettic": { "command": "npx", "args": [ "-y", "@arettic/mcp" ], "env": { "ARETTIC_API_KEY": "sk_test_…" } } } } ``` or the hosted endpoint: **mcp.json (hosted endpoint)** ```json { "mcpServers": { "arettic": { "url": "https://api.arettic.com/mcp", "headers": { "Authorization": "Bearer sk_test_…" } } } } ``` ### Other clients Any client that supports Streamable HTTP with a custom header can use `https://api.arettic.com/mcp` with `Authorization: Bearer `; most accept a `url` plus `headers` block like the Cursor one. A client that can only launch local servers uses the `command` block: the bridge works with any host that starts stdio servers. ## Call it from code Your own agent can talk to the hosted endpoint with any MCP client library, or with plain HTTP. Both samples below run recommend, then execute, then fetch the receipt when there is one. If you'd rather skip MCP, the [SDKs](/docs/sdks) call the REST API directly. **TypeScript (@modelcontextprotocol/sdk)** ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport(new URL("https://api.arettic.com/mcp"), { requestInit: { headers: { Authorization: `Bearer ${process.env.ARETTIC_API_KEY}` } }, }); const mcp = new Client({ name: "my-agent", version: "1.0.0" }); await mcp.connect(transport); // Every tool answers with one text item holding JSON. const body = (r: { content?: unknown }) => JSON.parse((r.content as { text: string }[])[0].text); const rec = await mcp.callTool({ name: "recommend", arguments: { task_type: "find_email" } }); const top = body(rec).options.find((o: { purchasable: boolean }) => o.purchasable); const bought = await mcp.callTool({ name: "execute", arguments: { tool_id: top.tool_id, input: { first_name: "Emily", last_name: "Carter", domain: "example.com" }, }, }); const outcome = body(bought); if (bought.isError) throw new Error(`${outcome.error.code}: ${outcome.error.message}`); console.log(outcome.status, outcome.result); // passed { email: "emily.carter@example.com", … } if (outcome.receipt_id) { // Live keys only: test purchases write no receipt. const receipt = await mcp.callTool({ name: "get_receipt", arguments: { receipt_id: outcome.receipt_id }, }); console.log(body(receipt)); } await mcp.close(); ``` **Python (standard library)** ```python import json import os import urllib.request API = "https://api.arettic.com" KEY = os.environ["ARETTIC_API_KEY"] def call_tool(name, arguments, rpc_id=1): body = json.dumps({ "jsonrpc": "2.0", "id": rpc_id, "method": "tools/call", "params": {"name": name, "arguments": arguments}, }).encode() req = urllib.request.Request( f"{API}/mcp", data=body, headers={ "Authorization": f"Bearer {KEY}", "Content-Type": "application/json", "Accept": "application/json, text/event-stream", }, method="POST", ) with urllib.request.urlopen(req, timeout=300) as res: result = json.load(res)["result"] data = json.loads(result["content"][0]["text"]) if result.get("isError"): raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}") return data rec = call_tool("recommend", {"task_type": "find_email"}) top = next(o for o in rec["options"] if o["purchasable"]) bought = call_tool("execute", { "tool_id": top["tool_id"], "input": {"first_name": "Emily", "last_name": "Carter", "domain": "example.com"}, }, rpc_id=2) print(bought["status"], bought.get("result")) if bought.get("receipt_id"): # live keys only print(call_tool("get_receipt", {"receipt_id": bought["receipt_id"]}, rpc_id=3)) ``` In the Python sample an HTTP-level refusal (401, 405, 406, 429) raises `urllib.error.HTTPError`; a tool error comes back inside the result and raises the `RuntimeError`. The MCP SDK client behaves the same way: transport failures throw, tool errors come back as results with `isError`. ## The nine tools Tool names, argument names and descriptions come from the hosted server, and `tools/list` returns them with JSON Schemas. Each tool runs the same code as its REST endpoint, so its answer has the same fields; the pages linked below describe those fields one by one. Money is in credits: 1 credit is $0.001, and every amount comes back as `{ "credits": "38", "usd": "0.038" }`. The nine tools and their REST equivalents | Tool | What it does | Same as | |---|---|---| | list_task_types | The task types, each with its pass rule, input and output. | `GET /v1/task-types` | | recommend | Ranked tools for a task: score, every score input, price per success, pass rule. | `POST /v1/recommend` | | get_tool | One tool in detail: scores by region, history, latest benchmark, schemas. | `GET /v1/tools/{id}` | | execute | Run a tool (one input, or up to 1,000 as inputs; over 25 runs as a job); charged only if the result passes its check. Test keys use mock providers. | `POST /v1/execute` | | get_job | Status and per-item results of a batch of more than 25 items. | `GET /v1/jobs/{id}` | | get_receipt | The receipt for a purchase: cost, check, outcome and refund. | `GET /v1/receipts/{id}` | | open_dispute | Dispute a charged item within 7 days (by receipt_id and item_index). Decided within 48 hours against the stored result; upheld means a refund. | `POST /v1/disputes` | | get_approval | Status of a purchase waiting for approval. | `GET /v1/approvals/{id}` | | get_balance | The agent's budget and the org's credit balance. | `GET /v1/balance` | ### list_task_types No arguments. Returns `task_types`, one entry per task type with `task_type`, `pass_rule` (for example `find_email@v1`), `passes_if`, `input` and `output`, plus the open-data `license`. The same list is on [Pass rules](/docs/pass-rules). ### recommend Ranked tools for one task. Send `task_type` or a plain-language `task`. Ranking is by score, then price when scores are within 2 points; whether a tool can be bought never changes its rank, it's the `purchasable` field. Up to 10 options come back. Every field is described on [Recommend](/docs/recommend). Arguments of recommend | Argument | Type | Required | Notes | |---|---|---|---| | task_type | string | one of task_type or task | One of `find_email`, `verify_email`, `enrich_company`, `enrich_person`, `web_search`, `extract_url`. | | task | string, up to 500 characters | one of task_type or task | Plain language, mapped to a task type by a keyword classifier. The answer says what it chose in `mapped_from_task`. If nothing matches, the error is [unknown_task_type](/docs/errors#unknown_task_type). Each free-text call uses one of your plan's daily free-text lookups. | | region | string, up to 10 characters | no | A region code such as `US`; default `GLOBAL`. Tools that cover `GLOBAL` always match. | | max_price | number | no | Most credits per success you'll accept. Costlier tools are left out. | | min_score | number, 0 to 100 | no | Tools scoring below this are left out. | | sort | string | no | `score` (default), `price`, `value` or `latency`. Within 2 points of score, price breaks the tie. | **MCP tool call** ```json { "name": "recommend", "arguments": { "task_type": "find_email" } } ``` **Response (example, test key)** ```json { "task_type": "find_email", "region": "GLOBAL", "sort": "score", "test_mode": true, "formula_version": "v1", "ranking": "score, then price when scores are within 2 points; purchasability never changes rank", "options": [ { "tool_id": "mock-find-email", "name": "Mock Email Finder", "provider": "Arettic Mock Provider", "score": 56.53, "score_week": "2026-09-28", "score_inputs": { "A": 0.4902, "S": 0.5958, "P": 0.4902, "R": 0.7225, "L": 1, "D": 0 }, "sample_size": 10, "price_per_success": { "credits": "38", "usd": "0.038" }, "success_rate": 0.5958, "p50_latency_ms": 5, "pass_rule": "find_email@v1", "purchasable": true, "regions": ["GLOBAL", "US"] } ] } ``` With a `task` instead of a `task_type`, the answer adds `mapped_from_task` with `from`, `confidence` and `alternatives`. A live key sees real tools here; a test key sees the mock ones. ### get_tool One tool in detail. The only argument is `tool_id` (a string of up to 80 characters: the id from `recommend`, such as `mock-find-email`). Returns `tool` (name, provider, task type, regions, `purchasable`, `price_per_success`, `prices_by_plan`, the `pass_rule` with its text, the `input` and `output` schemas, `test_mode`, `live_calls_available`), `scores` by region with every score input, `score_history`, and `latest_benchmark`. Unknown ids answer [unknown_tool](/docs/errors#unknown_tool). See [Public data](/docs/public-data) and [Scores](/docs/scores). ### execute Buys a result. The tool is run, the result is checked against the task type's published pass rule, and you're charged only if it passes. The full flow, every status and every field are on [Execute](/docs/execute). Arguments of execute | Argument | Type | Required | Notes | |---|---|---|---| | tool_id | string, up to 80 characters | yes | From `recommend`. | | input | object | one of input or inputs | One input in the task type's input shape (see `list_task_types`). | | inputs | array of objects, up to 1,000 | one of input or inputs | Up to 25 run now and answer with per-item results. More than 25 answer `status: "queued"` with a `job_id` for `get_job`. With a test key every size runs now. | | max_price | number | no | The most you'll pay in total, in credits. If the price (price per success times inputs) is above it, nothing runs: [price_above_max](/docs/errors#price_above_max). | | fallback | boolean | no | If an item fails, try the next-ranked tool once. Both attempts go on the receipt. | | approval_id | string, up to 40 characters | no | From a `status: "approval_required"` answer, once an owner has approved it. | | idempotency_key | string, up to 200 characters | no | The same key with the same request returns the same answer and never charges twice. The bridge adds one (`mcp-…`) when you don't send one. | **MCP tool call** ```json { "name": "execute", "arguments": { "tool_id": "mock-find-email", "input": { "first_name": "Emily", "last_name": "Carter", "domain": "example.com" } } } ``` **Response (example, test key, passed)** ```json { "test_mode": true, "tool_id": "mock-find-email", "task_type": "find_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "passed", "result": { "email": "emily.carter@example.com", "verification_status": "valid" }, "would_have_charged": { "credits": "38", "usd": "0.038" } } ``` **Response (example, test key, failed)** ```json { "test_mode": true, "tool_id": "mock-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "failed", "reason": "status:unknown", "would_have_charged": { "credits": "0", "usd": "0.000" } } ``` The `status` of one input is `passed`, `partial` (charged pro rata, with a `reason`) or `failed` (a `reason`, no `result`, nothing charged). A live key also gets `execution_id`, `receipt_id`, `refunded` and the real `charged` amount. A batch of up to 25 answers `status: "completed"` with a `summary` and per-item `items`. Two answers ask you to come back later: `status: "queued"` (with `job_id`, `items`, `max_charge`, `poll` and a `message`) when the batch is over 25 items, and `status: "approval_required"` (with `approval_id`, `reason`, `amount`, `expires_at` and a `message`) when the purchase is over the agent's approval threshold or monthly budget. ### get_job The only argument is `job_id`. Returns `job_id`, `status` (`queued`, `running`, `done`, `failed` or `expired`), `items`, `progress`, `created_at`, `started_at` and `finished_at`. Once the job has its receipt the answer also carries `receipt_id`, `tool_id`, `task_type`, `check_version`, `charged`, `refunded`, `summary` and the per-item `items` with results. A job you didn't start answers [not_found](/docs/errors#not_found). See [Batches and jobs](/docs/jobs). ### get_receipt The only argument is `receipt_id`. Returns the receipt: what was bought, from which provider, what it cost, the result's hash, the check and its version, the outcome and the refund, per item. Any agent of the org can read the org's receipts. Receipts never change; refunds from upheld disputes are added on top. Every field is on [Receipts](/docs/receipts). ### open_dispute Disputes a charged item. Only charged items can be disputed, within 7 days of the purchase, one dispute per item. Arettic decides within 48 hours against the stored copy of the result; a dispute not decided in time is upheld, and an upheld dispute refunds the item's charge. Test keys can't dispute: there was no charge. See [Disputes](/docs/disputes). Arguments of open_dispute | Argument | Type | Required | Notes | |---|---|---|---| | receipt_id | string, up to 40 characters | yes | The receipt the item is on. | | item_index | integer, 0 or more | no | Which item on the receipt. Default 0. | | reason | string | yes | `wrong_result`, `invalid_result`, `stale_result`, `duplicate_charge` or `other`. | | evidence | string, up to 4,000 characters | when reason is other | What's wrong with the result. | Returns `dispute_id`, `status` (`open` at first), `receipt_id`, `item_index`, `execution_id`, `tool_id`, `reason`, `evidence`, `charged`, `refunded`, `opened_at`, `decide_by` and `decided_at`. An item that wasn't charged answers [not_disputable](/docs/errors#not_disputable); one older than 7 days answers [dispute_window_closed](/docs/errors#dispute_window_closed). ### get_approval The only argument is `approval_id`, from an `approval_required` answer. Returns `approval_id`, `status` (`pending`, `approved`, `rejected`, `expired` or `used`), `reason` (`over_threshold` or `over_budget`), `agent`, `tool`, `items`, `amount`, `expires_at`, `decided_at` and `decision_note`. Approvals expire 24 hours after they're requested. Poll it until the status changes, then call `execute` again with the same `tool_id` and input plus the `approval_id`. See [Budgets and approvals](/docs/budgets-and-approvals). ### get_balance No arguments. Returns the org's balance and the agent's monthly budget. **Response (example)** ```json { "org_balance": { "paid": { "credits": "0", "usd": "0.000" }, "trial": { "credits": "0", "usd": "0.000" }, "total": { "credits": "0", "usd": "0.000" } }, "agent_spent_month": { "credits": "0", "usd": "0.000" }, "agent_budget": { "credits": "50000", "usd": "50.000" }, "agent_budget_left": { "credits": "50000", "usd": "50.000" } } ``` ## A typical session Three calls: `recommend` to pick a tool, `execute` to buy the result, `get_receipt` to see what happened. A test key covers the first two exactly as shown; the receipt needs a live key. 1. Call `recommend` with the task type. Take the first option whose `purchasable` is true and keep its `tool_id` and `price_per_success`. 2. Call `execute` with that `tool_id` and one `input`. Read `status`: `passed` gives `result` and `charged`; `failed` gives `reason` and charges nothing. If the answer is `approval_required`, poll `get_approval` until it's `approved`, then call `execute` again with the `approval_id`. If it's `queued`, poll `get_job` with the `job_id`. 3. With a live key the answer includes `receipt_id`. Call `get_receipt` with it. The receipt and `get_balance` agree: the balance drops by exactly `charged`. **Step 1: MCP tool call** ```json { "name": "recommend", "arguments": { "task_type": "verify_email" } } ``` **Step 2: MCP tool call** ```json { "name": "execute", "arguments": { "tool_id": "mock-verify-email", "input": { "email": "jason@acme.com" } } } ``` **Response (example, test key)** ```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" } } ``` With a live key, `recommend` ranks the real verify_email tools and `execute` charges the price of the one you chose. The answer then carries the ids you need for the receipt: **Response (example, live key)** ```json { "status": "passed", "execution_id": "0f6a9d2c-5b1e-4c7a-9e3d-2a8b7c6d5e4f", "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d", "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" } } ``` **Step 3: MCP tool call** ```json { "name": "get_receipt", "arguments": { "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d" } } ``` **Response (example, live key)** ```json { "receipt_id": "7c2e4b9a-1d3f-4a5b-8c6d-9e0f1a2b3c4d", "created_at": "2026-09-29T10:15:07.412Z", "tool_id": "hunter-verify-email", "job_id": null, "idempotency_key": "mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f", "request_hash": "…", "check_version": "v1", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "summary": { "items": 1, "passed": 1, "partial": 0, "failed": 0 }, "items": [ { "index": 0, "execution_id": "0f6a9d2c-5b1e-4c7a-9e3d-2a8b7c6d5e4f", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "captured", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "pricing_mode": "per_success", "input_hash": "…", "result_hash": "…", "check_version": "v1", "settled_at": "2026-09-29T10:15:07.398Z" } ] } ``` The three hashes are SHA-256 hex digests of the request, the item's input and the result, shortened here. The `idempotency_key` is the one the bridge generated; a call over the hosted endpoint without a key shows `null` there. Ids, times and prices are examples; live prices are on [Pricing](/pricing). ## Test mode With a test key, `recommend` and `execute` see the mock tools: one per task type, ids starting with `mock-`. No provider is called and no credits move. The answer carries `test_mode: true`, `charged` of zero, and `would_have_charged`: what a live purchase of the same result would have cost. The input decides the outcome, so you can rehearse both paths: Inputs that make a mock tool fail on purpose | Task type | Input | Outcome | |---|---|---| | any | any field contains `provider-error` | Provider error: `status: "failed"`, `reason: "provider_error"`, nothing charged. | | any | any field contains `nomatch` | No result: fails. | | find_email | `domain` ends in `.catchall.test` | Catch-all address: fails with `reason: "catch_all"`. | | verify_email | `email` starts with `unknown` | Unknown status: fails with `reason: "status:unknown"`. | | enrich_company | `domain` is `missing-fields.test` | Industry missing: fails. | | enrich_person | `company_domain` contains `nocontact` | No contact field: fails. | | web_search | `query` contains `few results` | 2 of 5 results: `partial`, charged pro rata. | | extract_url | `url` contains `blocked` | Captcha page: fails. | Everything else passes. For example, `mock-verify-email` passes `jason@acme.com` and fails `unknown@acme.com`; `mock-find-email` returns `emily.carter@example.com` for Emily Carter at `example.com`. Batches work in test mode too and always run inline, up to 1,000 inputs, so a test key never gets a `job_id`. Test purchases write no receipt, so there is no `receipt_id` to look up, and `open_dispute` answers [invalid_input](/docs/errors#invalid_input) ("Test-mode purchases are free; there's nothing to dispute"). Approvals only happen for live keys, so `get_approval` has nothing to show either. To go live, create a live agent in the dashboard and swap the key. Nothing else changes. The full test-mode reference, including how mock tools are scored, is on [Test mode](/docs/test-mode). ## Errors and retries ### Tool errors When the API refuses a call, the tool result has `isError: true` and its text is the API's error body: `code`, `message`, `doc_url` and `retryable`. `doc_url` points at the entry for that code on [Error codes](/docs/errors); `retryable` says whether trying again can help. A rate limit adds `retry_after_seconds`. **Response (example, isError: true)** ```json { "error": { "code": "unknown_task_type", "message": "Couldn't map that task to a task type. Send task_type as one of: find_email, verify_email, enrich_company, enrich_person, web_search, extract_url", "doc_url": "https://arettic.com/docs/errors#unknown_task_type", "retryable": false } } ``` The codes you'll meet most through MCP: [invalid_input](/docs/errors#invalid_input) (a field is missing or malformed; the message names it), [unknown_task_type](/docs/errors#unknown_task_type), [unknown_tool](/docs/errors#unknown_tool), [not_found](/docs/errors#not_found) (a receipt, job or approval that isn't yours), [insufficient_credits](/docs/errors#insufficient_credits), [over_budget](/docs/errors#over_budget), [price_above_max](/docs/errors#price_above_max), [tool_paused](/docs/errors#tool_paused), [provider_error](/docs/errors#provider_error) (nothing charged; retry or use `fallback: true`), [request_in_progress](/docs/errors#request_in_progress) (retry in a few seconds with the same `idempotency_key`) and [trial_limit](/docs/errors#trial_limit). ### Protocol errors A call to a tool that doesn't exist, or arguments that don't match the schema, is answered by the MCP layer rather than the API. The result still has `isError: true`, but its text is a plain sentence such as `MCP error -32602: Tool nope not found` or `MCP error -32602: Input validation error: Invalid arguments for tool recommend: …`. Nothing ran and nothing was charged; fix the call. The bridge passes these through unchanged. ### HTTP errors HTTP-level answers from the hosted endpoint | Status | When | Body | |---|---|---| | 401 | No key, an org key, or a revoked key. | [unauthenticated](/docs/errors#unauthenticated) | | 403 | The agent has an IP allowlist and this address isn't on it. | [ip_not_allowed](/docs/errors#ip_not_allowed) | | 405 | GET or DELETE on `/mcp`. | [method_not_allowed](/docs/errors#method_not_allowed), with `Allow: POST` | | 406 | The `Accept` header lacks `application/json` or `text/event-stream`. | A JSON-RPC error from the transport | | 415 | The `Content-Type` header isn't `application/json`. | A JSON-RPC error from the transport | | 429 | Over 600 requests a minute on this key. | [rate_limited](/docs/errors#rate_limited), with `Retry-After` | ### What the bridge does for you - If the API can't be reached, the tool result is `isError: true` with the code [connection_failed](/docs/errors#connection_failed). If the API doesn't answer within 5 minutes, the request is aborted and the code is [timeout](/docs/errors#timeout); the purchase may still be running, so it isn't retried. - If the connection drops during a call, the bridge reconnects and retries that call once. Reads are always retried. - An `execute` sent without an `idempotency_key` gets one generated (`mcp-…`), so the retry replays the first purchase instead of buying twice. If the call still fails, the error body includes that `idempotency_key` so you can retry it yourself. - `open_dispute` is retried only if the first attempt never reached the API, since a repeat would just report that the item already has a dispute. - A rate limit (429) isn't retried; the error carries `retry_after_seconds`. - Send your own `idempotency_key` on `execute` to make your own retries safe too. **Response (example, bridge, isError: true)** ```json { "error": { "code": "connection_failed", "message": "Couldn't reach the Arettic API at https://api.arettic.com (fetch failed). To retry without paying twice, call execute again with idempotency_key \"mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f\".", "retryable": true, "idempotency_key": "mcp-2b6d0f2e-0c4b-4a8e-9d1f-3c5e7a9b1d2f" } } ``` ## The server card `GET https://api.arettic.com/.well-known/mcp.json` describes the server for clients and directories: the endpoints, how to authenticate, where to get a key, the tools, and where the docs and the OpenAPI document are. No key is needed, and it allows cross-origin reads. **curl** ```bash curl https://api.arettic.com/.well-known/mcp.json ``` **Response (example)** ```json { "name": "arettic", "title": "Arettic", "version": "0.3.0", "description": "Ask which data tool works for a task, then buy the result through one key and pay only if it passes a published check.", "endpoints": { "streamable_http": "https://api.arettic.com/mcp", "stdio": { "command": "npx", "args": [ "-y", "@arettic/mcp" ], "env": [ "ARETTIC_API_KEY" ], "status": "published at launch" } }, "auth": { "type": "bearer", "header": "Authorization", "format": "Bearer sk_test_… or sk_live_…", "get_a_key": "https://arettic.com/docs" }, "tools": [ { "name": "list_task_types", "status": "available", "description": "The task types, each with its pass rule, input and output." }, { "name": "recommend", "status": "available", "description": "Ranked tools for a task: score, every score input, price per success, pass rule." }, { "name": "get_tool", "status": "available", "description": "One tool in detail: scores by region, history, latest benchmark, schemas." }, { "name": "execute", "status": "available", "description": "Run a tool (one input, or up to 1,000 as inputs; over 25 runs as a job); charged only if the result passes its check. Test keys use mock providers." }, { "name": "get_job", "status": "available", "description": "Status and per-item results of a batch of more than 25 items." }, { "name": "get_receipt", "status": "available", "description": "The receipt for a purchase: cost, check, outcome and refund." }, { "name": "open_dispute", "status": "available", "description": "Dispute a charged item within 7 days (by receipt_id and item_index). Decided within 48 hours against the stored result; upheld means a refund." }, { "name": "get_approval", "status": "available", "description": "Status of a purchase waiting for approval." }, { "name": "get_balance", "status": "available", "description": "The agent's budget and the org's credit balance." } ], "docs": "https://arettic.com/docs", "openapi": "https://api.arettic.com/openapi.json" } ``` `endpoints.stdio.status` says `published at launch` until the npm package is out. `version` is the hosted server's version, the same one `initialize` reports in `serverInfo`. ## Where next - [Quickstart](/docs/quickstart): from zero to a receipt, including how to get a key. - [Test mode](/docs/test-mode): the mock tools in full. - [Recommend](/docs/recommend) and [Execute](/docs/execute): every field of the two calls that matter most. - [Receipts](/docs/receipts), [Disputes](/docs/disputes), [Batches and jobs](/docs/jobs) and [Budgets and approvals](/docs/budgets-and-approvals): the rest of the tools, field by field. - [SDKs](/docs/sdks): the same calls without MCP. - [Error codes](/docs/errors): every code, what it means and what to do. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/mcp · Markdown: https://arettic.com/docs/mcp.md · JSON: https://arettic.com/docs/mcp.json --- # 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 ` | 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 --- # Recommend Ask which tool works for a task and get them ranked by score, then price. `POST /v1/recommend` tells your agent which tool to use for a task. You send one of the task types, or the task in plain language. You get back the tools for that task, ranked by score, each with the price of one passing result and the check that result must pass. The call is free: it uses no credits. It does count against your plan's daily lookup quota. Nothing is run or bought here. To buy a result, send the `tool_id` you pick to [execute](/docs/execute). It needs an agent key, `sk_test_…` or `sk_live_…`, in the `Authorization` header (see [Authentication](/docs/authentication)). Test keys see mock tools. Live keys see the real catalog. Without a key you can still read the catalog and scores, unranked, through [Public data](/docs/public-data). > Arettic is pre-launch. Keys go to design partners; everyone else can [join the waitlist](/waitlist). The `@arettic/sdk` (npm), `arettic` (PyPI) and `@arettic/mcp` packages used on this page are published at launch. ## Call it **curl** ```bash curl -s https://api.arettic.com/v1/recommend \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "task_type": "find_email", "region": "US", "constraints": { "max_price": 50, "min_score": 40 }, "sort": "score", "limit": 5 }' ``` This asks for `find_email` tools that serve the US, cost at most 50 credits per passing result and score at least 40, five at most. With a test key the only match is the mock finder: **Response (example)** ```json { "task_type": "find_email", "region": "US", "sort": "score", "test_mode": true, "formula_version": "v1", "ranking": "score, then price when scores are within 2 points; purchasability never changes rank", "options": [ { "tool_id": "mock-find-email", "name": "Mock Email Finder", "provider": "Arettic Mock Provider", "score": 56.53, "score_week": "2026-09-28", "score_inputs": { "A": 0.4902, "S": 0.5958, "P": 0.4902, "R": 0.7225, "L": 1, "D": 0 }, "sample_size": 10, "price_per_success": { "credits": "38", "usd": "0.038" }, "success_rate": 0.5958, "p50_latency_ms": 5, "pass_rule": "find_email@v1", "purchasable": true, "regions": ["GLOBAL", "US"] } ] } ``` `options[0].tool_id` is what you send to execute. Check `purchasable` first: ranking ignores it, so the top option can be a tool that is listed for information only (see [Info-only tools](#info-only)). ## Request `POST https://api.arettic.com/v1/recommend` with a JSON body. Headers: `Authorization: Bearer ` and `Content-Type: application/json`. Every field is optional, except that you must send `task_type` or `task`. Request fields | Field | Type | Default | What it does | |---|---|---|---| | task_type | string | none | One of the task types, listed below. Required unless you send `task`. When both are sent, `task_type` is used and `task` is ignored. | | task | string | none | The task in plain language, up to 500 characters (longer text is cut). Arettic maps it to a task type and says which one it chose. Counts as a free-text lookup as well as a score lookup. | | region | string | `GLOBAL` | Where the results are needed, for example `US`. Upper-cased. An empty string means `GLOBAL`. | | constraints.max_price | number | none | Price cap in credits (1 credit = $0.001). Tools whose price per success is above it are left out. Tools with no price stay in. | | constraints.min_score | number | none | Lowest score to include, on the 0 to 100 scale. Tools with no score yet are left out whenever you set it, even at 0. | | sort | string | `score` | `score`, `price`, `value` or `latency` (see [How tools are ranked](#ranking)). Anything else falls back to `score`. | | limit | number | 10 | How many options to return, 1 to 50. Decimals are rounded down, 0 becomes 1, and more than 50 becomes 50. | Numbers are checked strictly. `constraints.max_price`, `constraints.min_score` and `limit` must be non-negative numbers, or the call answers [invalid_input](/docs/errors#invalid_input) with a message that names the field, for example "`limit` must be a non-negative number". A body that isn't a JSON object answers `invalid_input` too. An unknown `sort` is not an error: it falls back to `score`. The task types: `find_email`, `verify_email`, `enrich_company`, `enrich_person`, `web_search`, `extract_url`. Send `task_type` when you know it. It skips the mapping step and the free-text quota. ## Response Response fields | Field | Meaning | |---|---| | task_type | The task type the options are for. | | mapped_from_task | Only when you sent `task`: how the text was mapped, as `from`, `confidence` and `alternatives`. See [Free-text tasks](#free-text). | | region | The region used, upper-cased. `GLOBAL` when you sent none. | | sort | The sort that was applied. | | test_mode | `true` for a test key. Test keys see mock tools only. | | formula_version | The version of the public score formula behind every `score`. Today `v1`. | | ranking | The ranking rule as a sentence, so an agent reading the JSON knows how the list was ordered. | | options | The ranked tools, at most `limit` of them. Empty when no tool matches. | ### Each option Option fields | Field | Type | Meaning | |---|---|---| | tool_id | string | The id you send to [execute](/docs/execute) as `tool_id`. | | name | string | The tool's name. | | provider | string | The provider's name. | | score | number or null | The tool's score, 0 to 100, from the public formula. `null` when the tool has no score yet. | | score_week | string or null | The Monday (UTC) of the week the score was computed for, as `YYYY-MM-DD`. | | score_inputs | object or null | Every input to the score: `A`, `S`, `P`, `R`, `L` and `D`, each between 0 and 1. The table below says what they are. | | sample_size | number | Data points behind the score: benchmark cases plus live calls in the last 28 days. `0` without a score. | | price_per_success | object or null | The pay-as-you-go price of one passing result, as `{ credits, usd }`, both strings (1 credit = $0.001). `max_price` is compared with this number. Pro and Max orgs pay less: `GET /v1/tools/{id}` shows `prices_by_plan`, and execute charges your plan's price. `null` when the tool has no price. | | success_rate | number or null | The same as `score_inputs.S`: the share of calls that passed the check. | | p50_latency_ms | number or null | Median latency in the tool's latest benchmark run, in milliseconds. `null` when it has none. | | pass_rule | string | The check a result must pass before you are charged, as `task_type@version`, for example `find_email@v1`. See [Pass rules](/docs/pass-rules). | | purchasable | boolean | Whether execute can buy it through Arettic. Never changes the rank. | | regions | string[] | The regions the tool serves, for example `["GLOBAL", "US"]`. | Score inputs | Input | Meaning | |---|---| | A | Accuracy: the share of benchmark cases where a correct result was delivered. | | S | Pass rate: benchmark and live calls in the last 28 days, weighted by volume. | | P | Audit precision: the share of audited passes confirmed correct. Uses `A` until 50 audits exist. | | R | Reliability: 1 minus the tool's error and timeout rate across its benchmark cases and live calls in the last 28 days. | | L | Speed: the task's median latency divided by this tool's latency, capped at 1. | | D | Upheld disputes divided by passed results. This one is a penalty. | The formula, its weights and its rules are on [How scores work](/docs/scores) and at `GET https://api.arettic.com/v1/formula`. ## How tools are ranked The default order is by score, highest first. Two scores within 2 points of each other count as a tie: the cheaper tool comes first, and at the same price the higher score. Tools with no score yet come after every scored tool, cheapest first. Whether a tool can be bought through Arettic never changes its rank. It is a field, `purchasable`, not a factor. In the API's own tests, a tool one point below the top scorer but cheaper ranks first, and a tool 30 points below ranks last even though it is sold here. Constraints are applied before sorting and `limit` after it. So `sort: "price"` with `limit: 1` gives you the cheapest tool that meets your constraints. Sorts | Sort | Order | |---|---| | score | The default. Highest score first, with the tie rule above. Tools with no score come last, cheapest first. | | price | Cheapest price per success first. Tools with no price come last. Equal prices keep the `score` order. | | value | Highest score divided by price in credits first. Tools with no score or no price come last. Equal values keep the `score` order. | | latency | Lowest `p50_latency_ms` first. Tools with no latency come last. Equal latencies keep the `score` order. | ## Info-only tools A tool is `purchasable` when it is curated for sale here and has a price. Every other listed tool is there for information only: you see its score and where it ranks, but execute refuses it with [not_purchasable](/docs/errors#not_purchasable) (HTTP 409). Its `price_per_success` may be `null`. So take the first option with `purchasable: true`, not `options[0]`. An info-only tool is still worth reading: it shows how the tool you buy compares with the rest of the market. ## Regions `region` says where the results are needed. It is upper-cased, so `us` and `US` are the same. Leave it out for `GLOBAL`. A tool is listed when its `regions` include your region or `GLOBAL`. Its score is the region's own score when one exists, otherwise its `GLOBAL` score; `score_week` and `score_inputs` come from whichever was used. The response repeats the region in `region`. A region with no tools of its own, or a value that isn't a region at all, gives you the tools that serve `GLOBAL` with their `GLOBAL` scores. ## Free-text tasks When you send `task` instead of `task_type`, Arettic maps the text to a task type with a keyword classifier. It is deterministic and calls no model: the same text always gives the same type. It costs one free-text lookup from your daily quota, nothing else. How it works: the text is lower-cased, extra spaces are collapsed, and anything after 500 characters is dropped. Each task type has a few keyword patterns with weights, for example "find … email" for `find_email`, "bounce" for `verify_email`, "search the web" for `web_search`. The type with the highest total wins, as long as that total is at least 2. Otherwise the call answers [unknown_task_type](/docs/errors#unknown_task_type) (HTTP 400): "Couldn't map that task to a task type. Send task_type as one of: …". The response always says what it chose, in `mapped_from_task`: mapped_from_task | Field | Meaning | |---|---| | from | Your text, trimmed, up to 500 characters. | | confidence | The winner's total divided by the winner's plus the runner-up's, rounded to 2 decimals. `1` when only one type matched. | | alternatives | Up to two other task types that also matched, best first. Empty when nothing else matched. | If the type is wrong, or `confidence` is low, send `task_type` explicitly. For example, this text matches two types and the runner-up may be the one you meant: **curl** ```bash curl -s https://api.arettic.com/v1/recommend \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "task": "find the email and job title of the CTO at acme.com" }' ``` **Response (example)** ```json { "task_type": "enrich_person", "mapped_from_task": { "from": "find the email and job title of the CTO at acme.com", "confidence": 0.57, "alternatives": ["find_email"] }, "region": "GLOBAL", "sort": "score", "test_mode": true, "formula_version": "v1", "ranking": "score, then price when scores are within 2 points; purchasability never changes rank", "options": [ { "tool_id": "mock-enrich-person", "name": "Mock Person Enrichment", "provider": "Arettic Mock Provider", "score": 62.87, "score_week": "2026-09-28", "score_inputs": { "A": 0.5958, "S": 0.5958, "P": 0.5958, "R": 0.7225, "L": 1, "D": 0 }, "sample_size": 10, "price_per_success": { "credits": "45", "usd": "0.045" }, "success_rate": 0.5958, "p50_latency_ms": 5, "pass_rule": "enrich_person@v1", "purchasable": true, "regions": ["GLOBAL", "US"] } ] } ``` Phrases from the API's own tests and where they map: Free-text examples | Task | Maps to | |---|---| | find the email of the marketing head at acme | `find_email` | | check if these emails will bounce | `verify_email` | | company size and industry for 100 US SaaS firms | `enrich_company` | | get the job title and linkedin of the CMO | `enrich_person` | | search the web for articles about sales tax compliance tools | `web_search` | | scrape the text from https://acme.com/about | `extract_url` | | make me a sandwich | Nothing: `unknown_task_type` | ## Daily quotas Recommend is free, but each org has a daily number of lookups that depends on its plan. All the org's agents share it, and it resets at 00:00 UTC. Every call that reaches the catalog counts one score lookup, whether or not any tool matches. A call with `task` also counts one free-text lookup. That one is taken before the text is mapped, so a task that can't be mapped still counts, though it takes no score lookup. Daily lookup quotas by plan | Plan | Name | Score lookups a day | Free-text lookups a day | |---|---|---|---| | payg | Pay as you go | 1,000 | 100 | | team | Pro | 10,000 | 500 | | enterprise | Max | No fixed limit | No fixed limit | When a quota is used up, the call answers HTTP 429 with the code [plan_limit](/docs/errors#plan_limit). The message says which limit it was. `retryable` is `false`, and the SDKs never retry recommend, so wait for the reset, or send `task_type` instead of `task` if it was the free-text quota that ran out. **Response (example)** ```json { "error": { "code": "plan_limit", "message": "Daily limit of 1000 score lookups reached for your plan. It resets at 00:00 UTC.", "doc_url": "https://arettic.com/docs/errors#plan_limit", "retryable": false } } ``` The per-agent rate limit applies on top of the quota: 600 requests a minute for each agent key, reported in the `RateLimit-*` headers. See [Rate limits and quotas](/docs/rate-limits). ## Test keys and mock tools With a test key (`sk_test_…`) the catalog is the mock provider: one tool per task type, ids starting with `mock-`, serving `GLOBAL` and `US`. They are benchmarked and scored like real tools, so constraints and sorts behave the same way. The response has `test_mode: true`. A live key never sees a mock tool, and a test key never sees a real one. Mock tools | Task type | Tool id | Name | Price per success (credits) | |---|---|---|---| | find_email | `mock-find-email` | Mock Email Finder | 38 | | verify_email | `mock-verify-email` | Mock Email Verifier | 6 | | enrich_company | `mock-enrich-company` | Mock Company Enrichment | 30 | | enrich_person | `mock-enrich-person` | Mock Person Enrichment | 45 | | web_search | `mock-web-search` | Mock Web Search | 8 | | extract_url | `mock-extract-url` | Mock Page Extractor | 3 | So `{"task_type": "find_email", "constraints": {"max_price": 10}}` returns no options with a test key: the mock finder costs 38 credits. With a live key you see only the tools that are switched on in the real catalog, so a task type can have no options until its providers are live. What the mock tools return from execute is on [Test mode](/docs/test-mode). ## SDK and MCP examples Both SDKs read `ARETTIC_API_KEY` (and `ARETTIC_API_URL`) from the environment. Neither retries recommend, because it is a POST without an idempotency key. A refused call throws (TypeScript) or raises (Python) `AretticApiError` with the API's `code`. See [SDKs](/docs/sdks). ### TypeScript **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; // Reads ARETTIC_API_KEY (and ARETTIC_API_URL) from the environment when you leave them out. const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); const rec = await arettic.recommend({ task_type: "find_email", region: "US", constraints: { max_price: 50, min_score: 40 }, sort: "score", limit: 5, }); // Ranking ignores whether a tool can be bought here, so take the first purchasable option. const tool = rec.options.find((o) => o.purchasable); if (!tool) throw new Error("No purchasable tool for " + rec.task_type + " in " + rec.region); console.log(tool.tool_id, tool.score, tool.price_per_success?.credits, tool.pass_rule); // mock-find-email 56.53 38 find_email@v1 // Or map plain language. The answer says which task type it chose. const mapped = await arettic.recommend({ task: "find the work email of Emily Carter at example.com" }); console.log(mapped.task_type, mapped.mapped_from_task?.confidence); // find_email 1 ``` ### Python **Python** ```python from arettic import Arettic client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) rec = client.recommend( task_type="find_email", region="US", constraints={"max_price": 50, "min_score": 40}, sort="score", limit=5, ) # Ranking ignores whether a tool can be bought here, so take the first purchasable option. tool = next((o for o in rec["options"] if o["purchasable"]), None) if tool is None: raise SystemExit(f"No purchasable tool for {rec['task_type']} in {rec['region']}") print(tool["tool_id"], tool["score"], tool["price_per_success"]["credits"], tool["pass_rule"]) # mock-find-email 56.53 38 find_email@v1 # Or map plain language. The answer says which task type it chose. mapped = client.recommend(task="find the work email of Emily Carter at example.com") print(mapped["task_type"], mapped["mapped_from_task"]["confidence"]) # find_email 1 ``` ### MCP The MCP server's `recommend` tool takes the same fields with two differences: `max_price` and `min_score` are top-level arguments (there is no `constraints` object), and there is no `limit`, so you get up to 10 options. `task` is limited to 500 characters, `region` to 10, and `min_score` to 100. The result is the same JSON as above, as one text content block. A refused call comes back as a result with `isError: true` and the API's error body. Setup is on [MCP server](/docs/mcp). **MCP tool call** ```json { "name": "recommend", "arguments": { "task_type": "find_email", "region": "US", "max_price": 50, "min_score": 40, "sort": "score" } } ``` ## Errors Errors from recommend | Code | HTTP | When | |---|---|---| | [invalid_input](/docs/errors#invalid_input) | HTTP 400 | The body isn't a JSON object, neither `task_type` nor `task` was sent, or `constraints.max_price`, `constraints.min_score` or `limit` isn't a non-negative number. The message names the field. | | [unknown_task_type](/docs/errors#unknown_task_type) | HTTP 400 | `task_type` isn't one of the task types, or `task` couldn't be mapped to one. | | [unauthenticated](/docs/errors#unauthenticated) | HTTP 401 | No agent key, or a revoked one. Only `sk_test_…` and `sk_live_…` keys work here; org keys (`ok_…`) don't. | | [plan_limit](/docs/errors#plan_limit) | HTTP 429 | The org's daily score-lookup or free-text quota is used up. It resets at 00:00 UTC. | | [rate_limited](/docs/errors#rate_limited) | HTTP 429 | Too many requests from this agent in a short time. Wait for the number of seconds in `Retry-After`. | Every error is `{ "error": { "code", "message", "doc_url", "retryable" } }`. `doc_url` points at the code's entry on [Error codes](/docs/errors). Updated 2026-09-29. This page as HTML: https://arettic.com/docs/recommend · Markdown: https://arettic.com/docs/recommend.md · JSON: https://arettic.com/docs/recommend.json --- # Execute Run a tool on one input or a batch, and pay only for items that pass the published check. `POST /v1/execute` buys results. You name a tool and send one input or a batch. Arettic checks the input for free, holds the price, calls the provider with its own credentials, runs the published pass rule on the answer, and settles: a pass is charged, a fail is refunded and its data withheld. Every live request ends in one immutable receipt. This page has every field, every status and every code, with examples in curl, TypeScript, Python and MCP. > Arettic is pre-launch. Keys go to design partners first; everyone else joins the [waitlist](/waitlist). The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. With a test key (`sk_test_…`) everything on this page works against mock providers and nothing is charged; see [Test keys](#test-keys). ## The request Send a JSON object with your agent key in `Authorization: Bearer sk_…`. The body must be under 1 MB. Fields not listed here are ignored. Execute request fields | Field | Type | Meaning | |---|---|---| | tool_id | string, required | The tool's id from [recommend](/docs/recommend) or `GET /v1/tools` (its slug, for example `hunter-verify-email`), or its UUID. A live key can't see `mock-` tools; a test key runs the mock provider for the tool's task type. | | input | object | One input. Its fields depend on the tool's task type; see the table below. | | inputs | array of 1 to 1,000 objects | A batch. Send `input` or `inputs`, not both. Up to 25 items run at once and come back in the same answer. More than 25 run as a job and the answer is `queued`. See [Batches](#batches). | | max_price | whole number of credits, as a JSON number or a string of digits | The most this whole request may cost. Price per item × items must be at or under it, or nothing runs and the answer is `price_above_max`. It also caps any fallback. See [max_price](#max-price). | | idempotency_key | string, 1 to 200 characters | Makes a retry safe: the same agent sending the same key gets the first answer back and is never charged twice. See [Idempotency](#idempotency). | | approval_id | approval UUID | From an `approval_required` answer, once an owner has approved it. The request must be exactly the approved one. See [Approvals](#approvals). | | fallback | boolean, default `false` | If an item fails its check or the provider errors, try the next-ranked tool for the task once, within `max_price`. Also lets a paused tool be replaced before the call. See [Fallback](#fallback). | The input fields come from the tool's task type. Every field is checked before any provider is called, and a request that fails the check costs nothing. The check is the same for live and test keys, except that test keys skip the DNS lookups (mock tools use made-up domains). Every problem is reported at once, the first ten in the message, in the form `input.field: problem` or `inputs[3].field: problem`, ending with `Nothing was charged.` If Arettic's own DNS can't answer, the request is not blocked. Input fields and the free pre-call check, per task type | task_type | Input fields | Checked before the call | |---|---|---| | find_email | `first_name`: string; `last_name`: string; `domain`: company domain, e.g. acme.com | `first_name` and `last_name` up to 100 characters. `domain` must look like a domain and must exist in DNS. | | verify_email | `email`: string | `email` must be an email address of up to 254 characters, and its domain must exist in DNS. | | enrich_company | `domain`: company domain; `name`: company name (if no domain); `country`: optional, with name | `domain` must look like a domain, or send `name` (up to 200 characters) instead. A domain must exist in DNS. | | enrich_person | `first_name`: string; `last_name`: string; `company_domain`: company domain | `first_name` and `last_name` up to 100 characters. `company_domain` must look like a domain and must exist in DNS. | | web_search | `query`: string, up to 500 characters; `n`: 1–25, default 5 | `query` up to 500 characters. `n`, if sent, is a whole number from 1 to 25; the default is 5. | | extract_url | `url`: http(s) URL | `url` must be a full http or https URL to a public host. Localhost, private and link-local addresses are refused. | The JSON Schemas for the request and the response are at [https://arettic.com/schemas](/schemas). The full pass rule of each task type, with its output fields, is on [Pass rules](/docs/pass-rules). ## What happens to a request Every live request goes through the same steps, in this order. Nothing is held until step 4, so anything refused before that is free. 1. **Validate (free).** The tool must exist, be purchasable and not paused; `input` or `inputs` must be present and within the limits; every input passes the syntax check, then the DNS and public-host checks. A problem answers `invalid_input`, `unknown_tool`, `not_purchasable` or `tool_paused`, and no provider is called. 2. **Price (free).** The price per item is the tool's price for your plan (pay as you go pays the listed price per success; Pro and Max pay their multiplier). `price × items` is the most the request can cost. If it is over `max_price`, the answer is `price_above_max`. 3. **Replay.** With an `idempotency_key` that this agent already used for the same request, the first answer comes back with `replayed: true`. Nothing runs and nothing is charged. 4. **Authorize.** The org's balance must cover `price × items`, or the answer is `insufficient_credits`. An org that has never topped up can hold at most 50 trial credits an hour, or the answer is `trial_limit`. Then, unless the request carries an `approval_id`, the amount is checked against the agent's approval threshold and its monthly budget: over either, the answer is `approval_required` (HTTP 202) and an owner is emailed. The agent can't change any of these limits. 5. **Hold.** In one transaction under the org's and the agent's row locks: the budget is checked again (two parallel requests can't overshoot it; the one that would answers `approval_required` with `over_budget`), the provider's per-org daily quota and the acceptable-use rule are applied, and the price is held for every item, trial credits first, then paid. A refusal here (`provider_quota`, `aup_limit`, `credits_frozen`) holds nothing. Over 25 items, the whole hold is placed now and the request becomes a job. 6. **Call.** Up to 4 items run at a time (8 in a job). Arettic calls the provider with its own credentials. Each attempt is cut off after 30 seconds, and there is one retry after an error or a timeout. A provider that answers 404 or 422 has no record: that counts as an empty answer, not an error. 7. **Check.** The published pass rule for the task type runs on the answer and gives `pass`, `partial` (web search only) or `fail` with a reason. For `find_email`, Arettic first verifies the address with its own verifier and the rule judges that verdict, so a catch-all never passes. The rule's version is `check_version` (`v1`), on the answer and on the receipt. If the checker itself throws, the item fails with `check_error`: you never pay for Arettic's bug. 8. **Settle.** Straight after the check. A pass captures the price. A partial captures `price × fraction`, rounded up, and releases the rest. A fail, a provider error or a timeout releases everything. Every item ends captured or released; an item caught mid-call by a crash is released within a few minutes with the reason `interrupted`. 9. **Deliver.** A passed or partial item comes back with its `result`. A failed item comes back with its `reason` only; the paid data is withheld. The inputs and results are stored encrypted for 7 days, so a replay can return them; the receipt keeps only hashes after that. ## Examples One input with a price cap and an idempotency key, with a live key. Use a `tool_id` your own `recommend` call returned; `hunter-verify-email` and its 7-credit price are an example. **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "hunter-verify-email", "input": { "email": "jason@acme.com" }, "max_price": 10, "idempotency_key": "verify-jason-1" }' ``` **TypeScript** ```ts import { Arettic, AretticApiError } from "@arettic/sdk"; // Reads ARETTIC_API_KEY (and ARETTIC_API_URL) from the environment when you leave them out. const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); try { const bought = await arettic.execute( { tool_id: "hunter-verify-email", input: { email: "jason@acme.com" }, max_price: 10 }, { idempotencyKey: "verify-jason-1" }, // else the SDK makes a UUID for this call ); switch (bought.status) { case "passed": case "partial": console.log(bought.result, bought.charged.credits); // "7" break; case "failed": console.log("not charged:", bought.reason); break; case "approval_required": console.log("waiting for an owner:", bought.approval_id); break; case "queued": // only when inputs has more than 25 items console.log((await arettic.waitForJob(bought.job_id)).summary); break; } } catch (err) { // Anything the API refused: err.code is a code from /docs/errors, e.g. "price_above_max". if (err instanceof AretticApiError) console.error(err.status, err.code, err.message); else throw err; } ``` **Python** ```python from arettic import Arettic, AretticApiError client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) try: bought = client.execute( { "tool_id": "hunter-verify-email", "input": {"email": "jason@acme.com"}, "max_price": 10, }, idempotency_key="verify-jason-1", # else the SDK makes a UUID for this call ) except AretticApiError as err: # Anything the API refused: err.code is a code from /docs/errors, e.g. "price_above_max". print(err.status, err.code, err.message) raise status = bought["status"] if status in ("passed", "partial"): print(bought["result"], bought["charged"]["credits"]) # "7" elif status == "failed": print("not charged:", bought["reason"]) elif status == "approval_required": print("waiting for an owner:", bought["approval_id"]) elif status == "queued": # only when inputs has more than 25 items print(client.wait_for_job(bought["job_id"])["summary"]) ``` Over MCP the `execute` tool takes the same fields as top-level arguments (`max_price` as a number). The tool result is the same JSON as text content; a refusal comes back as a tool error whose text is the error envelope. Client configs are on [MCP server](/docs/mcp). **MCP tool call** ```json { "name": "execute", "arguments": { "tool_id": "hunter-verify-email", "input": { "email": "jason@acme.com" }, "max_price": 10, "idempotency_key": "verify-jason-1" } } ``` **Response (example)** ```json { "status": "passed", "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "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" } } ``` ## Every response status Read `status` first. Money is always `{ "credits": "7", "usd": "0.007" }`: credits as a whole number in a string (1 credit is $0.001) and the same amount in dollars with three decimals. Execute statuses | status | HTTP | When | What comes with it | |---|---|---|---| | passed | 200 | One `input`, and the result passed the check. | `result`, `charged` (the price), `refunded` (0), `execution_id`, `receipt_id`. | | partial | 200 | One `input` for `web_search`, and fewer results than asked for. | `result`, `reason` (`results:2/5`), `charged` pro rata, `refunded` the rest. | | failed | 200 | One `input`, and the check failed, the provider errored or timed out. | `reason` only: no `result`, `charged` is 0, `refunded` is the full price. (Under [per-call pricing](#per-call-pricing) a failed check is charged and keeps its `result`.) | | completed | 200 | `inputs` with up to 25 items, all run and settled. | `summary` and `items[]`, one per input in order, each with its own status. `charged` and `refunded` are the totals. | | queued | 202 | `inputs` with more than 25 items, with a live key. | `job_id`, `items`, `max_charge`, `poll`, `message`. Poll `GET /v1/jobs/{job_id}`. | | approval_required | 202 | The amount is over the agent's approval threshold or its monthly budget. | `approval_id`, `reason` (`over_threshold` or `over_budget`), `amount`, `expires_at`, `message`. Nothing ran. | | declined | 402, 403, 409, 410 or 429 | The org can't pay, the trial limit or a quota is hit, or an approval can't be used. | An `error` next to it with the code. Nothing ran and nothing is held. See [Errors](#errors). | A failed check or a provider error is not an HTTP error. The answer is 200 with `status: "failed"` and a `reason`, and nothing is charged. Only refusals use the error envelope. ### failed **Response (example)** ```json { "status": "failed", "execution_id": "9b1f0d2e-4c6a-4e8b-9f3d-2a7c5e1b8d40", "receipt_id": "0d2e7f31-8a4b-4c9d-b1e6-5f7a9c3d2e10", "tool_id": "hunter-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "refunded": { "credits": "7", "usd": "0.007" }, "reason": "status:unknown" } ``` ### partial (pro rata) Only `web_search` can be partial. You ask for `n` results (default 5). If the provider returns at least `n` valid, distinct URLs the item passes. If it returns some but fewer, you get them all and pay `price × found ÷ n`, rounded up to a whole credit. No usable URL is a fail with `no_result`. Two of five results on a 10-credit tool costs 4 credits: **Response (example)** ```json { "status": "partial", "execution_id": "5e8c1a7b-2d3f-4a6e-8b9c-1f2e3d4c5b6a", "receipt_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "tool_id": "exa-web-search", "task_type": "web_search", "check_version": "v1", "charged": { "credits": "4", "usd": "0.004" }, "refunded": { "credits": "6", "usd": "0.006" }, "result": { "results": [ { "url": "https://example.com/saas-directory", "title": "US SaaS companies", "snippet": "A directory of…" }, { "url": "https://acme.com/blog/saas-in-the-us", "title": "SaaS in the US", "snippet": "The market…" } ] }, "reason": "results:2/5" } ``` ### completed (a batch of 25 or fewer) Each item is held, called, checked and settled on its own, so one bad input never affects the others. `items[]` keeps the order of `inputs`; each entry has `index`, `status`, `charged`, and `result` or `reason`. The per-item `execution_id` and refund are on the receipt. Three verify-email inputs, one of them failing: **Response (example)** ```json { "status": "completed", "receipt_id": "7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "tool_id": "hunter-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "14", "usd": "0.014" }, "refunded": { "credits": "7", "usd": "0.007" }, "summary": { "items": 3, "passed": 2, "partial": 0, "failed": 1 }, "items": [ { "index": 0, "status": "passed", "charged": { "credits": "7", "usd": "0.007" }, "result": { "email": "emily@acme.com", "status": "valid" } }, { "index": 1, "status": "failed", "charged": { "credits": "0", "usd": "0.000" }, "reason": "status:unknown" }, { "index": 2, "status": "passed", "charged": { "credits": "7", "usd": "0.007" }, "result": { "email": "jason@acme.com", "status": "valid" } } ] } ``` ### queued (a batch of more than 25) The whole hold (`max_charge`) is reserved now, and a worker runs the items. Poll the job; once it has its receipt the job answer carries the same `summary` and `items[]` as a completed batch. Details, states and the `job.completed` webhook are on [Batches and jobs](/docs/jobs). **Response (example)** ```json { "status": "queued", "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "items": 30, "max_charge": { "credits": "210", "usd": "0.210" }, "poll": "/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "message": "Running 30 items as a job. Poll the job for progress; each item is charged only if it passes." } ``` ### approval_required The purchase waits for an owner. `reason` is `over_threshold` (the amount is above the agent's per-purchase approval threshold) or `over_budget` (it would take the agent over its monthly budget). Two inputs at 7 credits for an agent whose threshold is 10 credits: **Response (example)** ```json { "status": "approval_required", "approval_id": "b4c3d2e1-f0a9-4b8c-7d6e-5f4a3b2c1d0e", "reason": "over_threshold", "amount": { "credits": "14", "usd": "0.014" }, "expires_at": "2026-09-30T18:04:11.512Z", "message": "This is above the agent's approval threshold. An owner has been asked to approve it; retry with approval_id once approved." } ``` ## Response fields The fields of a single-input answer. A batch answer has the same top-level fields except `execution_id`, `result` and `reason`, which move into `items[]`. Execute response fields | Field | Meaning | |---|---| | status | `passed`, `partial` or `failed` for one input; `completed` for a batch. | | execution_id | This item's id. A fallback that ran gives the id of the attempt that served the answer; the first attempt's id is in `attempts[]`. | | receipt_id | The receipt for the whole request: one per request, and one per job. Fetch it with `GET /v1/receipts/{receipt_id}`. See [Receipts](/docs/receipts). | | tool_id, task_type | The tool that was run (its slug) and its task type. With a paused-tool substitution `tool_id` is the replacement and `substituted_for` names the tool you asked for. | | check_version | The version of the pass rule that judged the result (`v1`). It is on the receipt too. | | charged | What was taken: the price on a pass, pro rata on a partial, 0 on a fail. On a batch, the total. | | refunded | What was held but not taken, already back in the balance. `charged + refunded` is the hold. | | result | The provider's answer, in the task type's output shape. Present on `passed` and `partial` only. | | reason | Why the item failed or was partial. See [Reason codes](#reason-codes). | | served_by, attempts, fallback_note | Only with `fallback: true`. See [Fallback](#fallback). | | substituted_for | The paused tool you asked for, when `fallback: true` replaced it before the call. | | pricing_mode | `per_call` when the org is on per-call pricing for this tool; absent otherwise. See [Per-call pricing](#per-call-pricing). | | replayed, note | `replayed: true` when this answer is the stored answer to an earlier request with the same idempotency key; `note` says so when its result data has been deleted. See [Idempotency](#idempotency). | | summary | Batches only: `{ items, passed, partial, failed }`. | | items[] | Batches only, one per input in order: `index`, `status`, `charged`, `result` or `reason`, and the fallback fields. | ## Reason codes `reason` is a short machine-readable string. A fail is never charged, except a failed check under [per-call pricing](#per-call-pricing). The only partial reason is `results:found/n` on web search, charged pro rata. Some reasons carry a value after a colon. Reason codes on failed and partial items | reason | Task types | Meaning | |---|---|---| | no_result | all | The provider had no record, or the answer was empty: no email, no results, no company, no page. | | catch_all | find_email | An address was found but the domain accepts any address, so it can't be verified. | | status: | find_email, verify_email | The verifier's status wasn't definitive: `status:unknown`, `status:catch_all`, or for find_email `status:invalid`. Verify-email passes on `valid` and on `invalid`, because both are true answers. | | domain_mismatch, name_mismatch, company_mismatch | enrich_company, enrich_person | The record is about something else: its domain, name (similarity under 0.9) or company domain doesn't match your input. | | field_missing: | enrich_company, enrich_person | A required output field is empty: `name`, `domain`, `employee_range` or `industry` for a company; `title` or `contact` (no email, phone or LinkedIn URL) for a person. | | results:/ | web_search | Partial: fewer valid, distinct URLs than the `n` you asked for. Charged `price × found ÷ n`, rounded up. | | http:, blocked_page, content_too_short | extract_url | The page didn't answer 200 (`http:403`, `http:none`), looked like a captcha or block page, or had under 200 characters of main content. | | provider_error | all | The provider failed on both attempts: a 5xx, a 429, another 4xx such as a rejected key, or a connection failure. Free: neither you nor Arettic pays. | | timeout | all | No answer within 30 seconds, twice. | | check_error | all | Arettic's checker threw on this result. Counted as a fail so you never pay for Arettic's bug. | | interrupted, internal_error, cancelled | all | The item was caught by a crash mid-call, an unexpected error, or a cancelled job before it ran. Released, never charged. | | expired | jobs | The job hit its 2-hour limit before this item ran. Released. | ## Test keys With a test key (`sk_test_…`) `execute` runs the mock provider for the tool's task type and applies the real pass rule with the same `check_version`. The answer has the same shape as live, plus `test_mode: true`, a `charged` of 0 and `would_have_charged`: the tool's listed price per success (0 on a fail, pro rata on a partial). No credits move, nothing is held, and no receipt is written, so there is no `execution_id`, `receipt_id` or `refunded`. Batches of any size up to 1,000 run inline, so a test key never answers `queued`, and it never needs an approval. A test key ignores `max_price`, `idempotency_key`, `approval_id` and `fallback`. The inputs that make each mock tool pass, fail or go partial are on [Test mode](/docs/test-mode). **Response (example): test key, one input** ```json { "test_mode": true, "tool_id": "mock-find-email", "task_type": "find_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "status": "passed", "result": { "email": "emily.carter@acme.com", "verification_status": "valid" }, "would_have_charged": { "credits": "38", "usd": "0.038" } } ``` A test batch answers `status: "completed"` with `test_mode`, `tool_id`, `charged` (0), `would_have_charged` (the sum), `summary` and `items[]`. Each item has `index`, `status`, `result` or `reason`, and its own `would_have_charged` instead of `charged`. ## Batches `inputs` takes 1 to 1,000 objects for one tool. All of them are validated and priced together: one bad input refuses the whole request with `invalid_input`, before anything is held, and the message lists the bad items by index. `max_price` caps the whole batch. - **25 or fewer** run in the request. Every item is held at once, then up to 4 items run at a time, each settling as soon as it is checked. The answer is `completed` with `items[]`. With slow providers a full batch can take longer than a client's default timeout (60 seconds in both SDKs); raise it, or send the batch as a job. - **More than 25** become a job. The hold for every item is placed before the answer, the inputs wait encrypted, and a worker runs them 8 at a time with a 2-hour limit. The answer is `queued` (HTTP 202) with a `job_id`. Items not run within 2 hours are released with the reason `expired`. Poll `GET /v1/jobs/{job_id}` (the MCP tool is `get_job`; the SDKs have `waitForJob` and `wait_for_job`). While it runs the answer has `status` (`queued` or `running`), `items` (the count) and `progress`. When it is `done` (or `expired`) it also has `receipt_id`, `charged`, `refunded`, `summary` and `items[]` with every item's outcome and result, like a completed batch. The org also gets a `job.completed` webhook. Everything about jobs is on [Batches and jobs](/docs/jobs). **curl: poll a job** ```bash curl https://api.arettic.com/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21 \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` ## max_price `max_price` is a cap on the whole request, in whole credits. When you leave it out, the cap is the request's own price. The price per item is read once, when the request is validated, and fixed for that request: `price × items` is compared with `max_price` before anything is held, and a request over the cap is refused with `price_above_max` (HTTP 402). There is no separate price-changed error. If a tool's price rises between two calls, the next call that is over your cap is refused the same way, and `charged` on every answer tells you what was taken. **Response (example): 2 inputs at 7 credits with max_price 13** ```json { "error": { "code": "price_above_max", "message": "This costs up to 14 credits (7 × 2), above your max_price of 13.", "doc_url": "https://arettic.com/docs/errors#price_above_max", "retryable": false } } ``` With `fallback: true`, the cap also limits the second tool: each item may fall back to a tool priced at or under `max_price ÷ items`. Without `max_price` that is the original tool's price, so a fallback never costs more than what you asked for. You can never be charged more than `max_price`, and never more than `price × items`. ## Idempotency Send an `idempotency_key` (1 to 200 characters) with every live purchase. It is scoped to the agent: the same agent sending the same key and the same request gets the first answer back, with `replayed: true`, and is never charged twice. "The same request" means the same tool and the same inputs; key order inside an input doesn't matter. The replay carries the stored result while it exists (7 days); after that it carries the receipt and the charges with a `note`. A replay never needs credits and never calls a provider. - Same key, same request, finished: HTTP 200, the first answer, plus `replayed: true`. - Same key, same request, still running (or two identical requests sent at once): HTTP 409 `request_in_progress`, which is retryable. Only one of them runs and is charged; send it again in a few seconds to get the receipt. - Same key, different request: HTTP 409 `idempotency_conflict`. Use a new key for a new request. - No key: every request is a new purchase. A retry after a dropped connection buys again. Both SDKs add a fresh UUID to every `execute` call and reuse it on that call's retries, so a retry after a timeout replays instead of buying twice. Pass your own key (`idempotencyKey` in TypeScript, `idempotency_key` in Python) to keep retries safe across restarts of your program. Jobs work the same way: the same key while the job runs answers `request_in_progress`, and once the job has its receipt the replay returns its items like a completed batch. Test keys ignore the field. **Response (example): the same key sent again** ```json { "status": "passed", "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "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" }, "replayed": true } ``` **Response (example): the same key while the first request is still running** ```json { "error": { "code": "request_in_progress", "message": "A request with this idempotency_key is still running. Retry in a few seconds to get its receipt.", "doc_url": "https://arettic.com/docs/errors#request_in_progress", "retryable": true } } ``` ## Fallback Fallback is opt-in with `fallback: true`. When an item fails its check, or the provider errors or times out, Arettic tries once more with the next-ranked tool for the same task type: a purchasable, active tool with a working provider connection, other than the one you asked for, with the best score in the item's region (a regional score when the input's domain points to a region the tool covers, else `GLOBAL`), then the lowest price, and priced at or under `max_price ÷ items`. The first attempt was already released, so only a passing tool is charged. There is at most one fallback per item, and one receipt lists both attempts. The second attempt is opened under the same checks as a request (the agent's budget, the org's balance, quotas). If any of them says no, the item stays failed and `fallback_note` says why. Requests sent with an `approval_id` never fall back: an approval covers exactly the tool it was granted for. Jobs fall back per item too. Fallback fields on an item | Field | Meaning | |---|---| | served_by | The tool that gave the answer, when it was the fallback. `tool_id` stays the tool you asked for. | | attempts[] | Both attempts in order: `execution_id`, `tool_id`, `status`, `reason` (if any) and `charged`. Present whenever a fallback was considered. | | fallback_note | Why no second attempt ran: `no other tool fits within max_price`, or `the fallback wasn't allowed (budget, balance or quota)`. | | charged, refunded | Summed over both attempts: the failed attempt's full price is in `refunded`, the passing attempt's price in `charged`. | **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "hunter-verify-email", "input": { "email": "jason@acme.com" }, "fallback": true, "idempotency_key": "verify-jason-2" }' ``` Hunter can't verify the address (`status:unknown`) and ZeroBounce, at 6 credits, can. The 7 credits held for the first attempt go back; 6 are charged: **Response (example)** ```json { "status": "passed", "execution_id": "d7e6f5a4-b3c2-4d1e-9f0a-8b7c6d5e4f30", "receipt_id": "e8f7a6b5-c4d3-4e2f-8a1b-9c0d1e2f3a41", "tool_id": "hunter-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "6", "usd": "0.006" }, "refunded": { "credits": "7", "usd": "0.007" }, "result": { "email": "jason@acme.com", "status": "valid" }, "served_by": "zerobounce-verify-email", "attempts": [ { "execution_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d", "tool_id": "hunter-verify-email", "status": "failed", "reason": "status:unknown", "charged": { "credits": "0", "usd": "0.000" } }, { "execution_id": "d7e6f5a4-b3c2-4d1e-9f0a-8b7c6d5e4f30", "tool_id": "zerobounce-verify-email", "status": "passed", "charged": { "credits": "6", "usd": "0.006" } } ] } ``` A paused tool (an outage, a loss-making price or a provider problem) refuses requests with `tool_paused`. With `fallback: true` it is replaced before the call instead: the best other tool within the cap runs, `tool_id` is that tool, and `substituted_for` is the one you asked for. If no other tool fits, the answer is `tool_paused`. ## Approvals Every agent has a monthly budget (default $50) and an approval threshold (default: any single purchase over $20; owners can set it to never ask). A live request over the threshold, or one that would take the agent over its budget, answers HTTP 202 `approval_required` with an `approval_id`, and every owner gets an email with a one-click decision page. The same request sent again while the decision is pending answers the same `approval_id` and sends no second email. Approvals expire after 24 hours. Poll `GET /v1/approvals/{approval_id}` (`get_approval` over MCP, `waitForApproval` in the TypeScript SDK) until `status` is no longer `pending`. When it is `approved`, send exactly the same request again with `approval_id` added. It then skips the threshold and budget checks, and the approval is used up. An approval is locked to its agent, tool, inputs and amount: change any of them and the answer is `approval_mismatch`. A `rejected` approval answers `approval_rejected`; an expired one answers `approval_expired`, so send a fresh request to ask again. The owner's side, the events and the dashboard are on [Budgets and approvals](/docs/budgets-and-approvals). **curl: resend with the approval** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "hunter-verify-email", "inputs": [{ "email": "emily@acme.com" }, { "email": "jason@acme.com" }], "approval_id": "b4c3d2e1-f0a9-4b8c-7d6e-5f4a3b2c1d0e" }' ``` ## Per-call pricing Pay-per-success assumes honest inputs. If an org's fail rate on a tool is more than twice the tool's baseline over at least 200 calls, that org is moved to per-call pricing for that tool: every checked call is charged, pass or fail, at the provider's cost times the plan's multiplier, rounded up. Provider errors and timeouts stay free. The owners get an email saying why. The answer then carries `pricing_mode: "per_call"`, and a failed item that was charged does include its `result`. The rule is reviewed after 30 days or 200 more calls and lifted when the fail rate is back in line. Checking inputs before you send them (real names, domains that exist) keeps you off it. ## Errors Anything the API refuses comes back as `{ "error": { "code", "message", "doc_url", "retryable" } }`. `doc_url` links to the code's entry on [Error codes](/docs/errors), and `retryable` says whether sending the same request again later can work. Refusals about money, limits and approvals add `"status": "declined"` next to `error`. Nothing is held on any refusal. The SDKs raise every refusal as `AretticApiError` with the same fields. **Response (example): declined** ```json { "status": "declined", "error": { "code": "insufficient_credits", "message": "This needs 1050 credits ($1.050); the org has 1000 ($1.000). Top up to continue.", "doc_url": "https://arettic.com/docs/errors#insufficient_credits", "retryable": false } } ``` Error codes execute can answer, in the order they are checked | Code | HTTP | When | |---|---|---| | [unauthenticated](/docs/errors#unauthenticated) | 401 | The key is missing, wrong or revoked, or the agent is disabled. | | [ip_not_allowed](/docs/errors#ip_not_allowed) | 403 | The key has an IP allowlist and this request came from elsewhere. | | [rate_limited](/docs/errors#rate_limited) | 429 | Too many requests from this agent. The `RateLimit-*` and `Retry-After` headers say when to retry. See [Rate limits](/docs/rate-limits). | | [payload_too_large](/docs/errors#payload_too_large) | 413 | The body is over 1 MB. | | [invalid_input](/docs/errors#invalid_input) | 400 | A field is missing or malformed: no `tool_id`, both `input` and `inputs`, an empty batch, over 1,000 inputs, a bad `max_price`, `idempotency_key`, `approval_id` or `fallback`, or an input that fails its task type's check. The message names every problem. Nothing was charged. | | [unknown_tool](/docs/errors#unknown_tool) | 404 | No tool with that id or slug for your key. Live keys can't see `mock-` tools. | | [tool_paused](/docs/errors#tool_paused) | 409 | The tool is paused and the request didn't ask for `fallback`, or no other tool fits. | | [not_purchasable](/docs/errors#not_purchasable) | 409 | The tool is listed for information only, isn't live, or has no price yet. | | [price_above_max](/docs/errors#price_above_max) | 402 | `price × items` is above `max_price`. | | [idempotency_conflict](/docs/errors#idempotency_conflict) | 409 | The `idempotency_key` was already used by this agent for a different request. | | [request_in_progress](/docs/errors#request_in_progress) | 409 | The request with this `idempotency_key` is still running. Retryable. | | [insufficient_credits](/docs/errors#insufficient_credits) | 402 | The org's balance can't cover `price × items`. Declined. | | [trial_limit](/docs/errors#trial_limit) | 429 | The org has never topped up and this would take it over 50 trial credits held in the last hour. Declined, retryable. | | [approval_mismatch](/docs/errors#approval_mismatch) | 403 or 409 | The `approval_id` belongs to another agent (403), was already used, or the tool, inputs or amount differ from what was approved (409). Declined. | | [approval_rejected](/docs/errors#approval_rejected) | 403 | An owner declined this purchase. Declined. | | [approval_expired](/docs/errors#approval_expired) | 410 | The approval is older than 24 hours. Send the request again to ask afresh. Declined. | | [credits_frozen](/docs/errors#credits_frozen) | 403 | Arettic has frozen the org's credits. Reads still work. Declined. | | [provider_quota](/docs/errors#provider_quota) | 429 | The org reached today's call limit for this tool's provider. It resets at 00:00 UTC; other tools for the task still work. Declined, retryable. | | [aup_limit](/docs/errors#aup_limit) | 429 | More than 500 people lookups (find, enrich or verify) at one company domain today. Bulk collection of a company's staff isn't allowed. Declined. | | [internal_error](/docs/errors#internal_error) | 500 | Something went wrong on Arettic's side. Nothing was charged. Retryable. | `provider_error` and `check_failed` on the error codes page are not refusals: on this endpoint they are reasons on a `failed` item (HTTP 200), never charged. `approval_required` is a status (HTTP 202), not an error. ## Where next - [Receipts](/docs/receipts): the receipt field by field, with every item's provider, hashes, check version and outcome. - [Disputes](/docs/disputes): a charged result looks wrong? Dispute it within 7 days; decided within 48 hours against the stored copy. - [Batches and jobs](/docs/jobs): states, polling, expiry and the `job.completed` webhook. - [Budgets and approvals](/docs/budgets-and-approvals): the owner's side of `approval_required`. - [Pass rules](/docs/pass-rules): what passes, what fails and what is refunded, per task type. - [Test mode](/docs/test-mode): every mock tool and the inputs that make it pass, fail or go partial. - [Error codes](/docs/errors) and the [JSON Schemas](/schemas) for the request, the response and the receipt. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/execute · Markdown: https://arettic.com/docs/execute.md · JSON: https://arettic.com/docs/execute.json --- # Batches and jobs More than 25 inputs run as a job: the hold, the states, polling, and the completion webhook. `POST /v1/execute` takes one input or a batch of `inputs` for one tool. A batch of 25 or fewer runs inside the request and comes back with every item's outcome. A batch of more than 25 becomes a **job**: the price of every item is held at once, the answer is `queued` (HTTP 202) with a `job_id`, and a worker runs the items in the background. You poll `GET /v1/jobs/{job_id}`, or wait for the `job.completed` webhook, and the finished job carries the same per-item results as a completed batch. Each item is still charged only if it passes its check. This page has every number, every state and every field, with examples in curl, TypeScript, Python and MCP. The request fields, the pass rules and the reason codes are on [Execute](/docs/execute). > Arettic is pre-launch. Keys go to design partners first; everyone else joins the [waitlist](/waitlist). The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. Test keys (`sk_test_…`) never create jobs: every batch runs at once and nothing is charged; see [Test keys](#test-keys). ## Batch or job: the numbers The size of `inputs` decides what happens. Every batch is validated and priced as a whole before anything is held: one bad input refuses the entire request with `invalid_input`, and `price × items` must be at or under `max_price`, or the answer is `price_above_max`. Nothing is charged for a refusal. What happens by the number of inputs, with a live key | Inputs | Runs | Answer | Concurrency and time limit | |---|---|---|---| | 1 (`input`) | In the request | `passed`, `partial` or `failed` (HTTP 200) | One call; 30 seconds per attempt, one retry. | | 2 to 25 (`inputs`) | In the request | `completed` with `summary` and `items[]` (HTTP 200) | Up to 4 items at a time. A slow batch can outlast a client's timeout (60 seconds in both SDKs); raise it or send it as a job. | | 26 to 1,000 (`inputs`) | As a job, by a worker | `queued` with `job_id` (HTTP 202) | Up to 8 items at a time, 2 hours from the moment a worker starts it. Items not run by then are released as `expired`. | | More than 1,000 | Nothing | `invalid_input`: "A batch can have at most 1000 inputs" (HTTP 400) | Split the list into requests of up to 1,000. | There is no field to force a job for a small batch or to run a large batch inline: 25 is the line. The one exception is a test key, which runs every size inline. ## Submit a job A job is an ordinary execute request with more than 25 `inputs`. Every field of [the execute request](/docs/execute#request) applies: `max_price` caps the whole job, `idempotency_key` makes a retry safe, `approval_id` carries an owner's approval, and `fallback: true` gives each failing item one more try with the next-ranked tool. Use a `tool_id` your own `recommend` call returned; `hunter-verify-email` at 7 credits per success is the example throughout, so 30 inputs hold 210 credits. **curl** ```bash curl https://api.arettic.com/v1/execute \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool_id": "hunter-verify-email", "inputs": [ { "email": "emily@acme.com" }, { "email": "jason@example.com" }, { "email": "p3@acme.com" } ], "max_price": 210, "idempotency_key": "verify-batch-2026-09-29" }' # ... with 30 objects in inputs, not 3. Over 25, the answer is queued. ``` **Response (example)** ```json { "status": "queued", "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "items": 30, "max_charge": { "credits": "210", "usd": "0.210" }, "poll": "/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "message": "Running 30 items as a job. Poll the job for progress; each item is charged only if it passes." } ``` The queued answer, field by field | Field | Meaning | |---|---| | status | Always `queued`. The HTTP status is 202. | | job_id | The job's UUID. Only the agent that submitted the job can read it. | | items | How many inputs the job has. | | max_charge | `price × items`: the most the job can cost, and exactly what was held. Money is `{ credits, usd }`: credits as a whole number in a string (1 credit is $0.001) and the same amount in dollars with three decimals. | | poll | The path to poll, relative to the API host: `GET https://api.arettic.com/v1/jobs/{job_id}`. | | message | One sentence for a person or an agent reading the answer. | The same request over MCP uses the `execute` tool with the same fields as top-level arguments. The tool result is the same JSON as text content. Client configs are on [MCP server](/docs/mcp). **MCP tool call** ```json { "name": "execute", "arguments": { "tool_id": "hunter-verify-email", "inputs": [{ "email": "emily@acme.com" }, { "email": "jason@example.com" }], "max_price": 210, "idempotency_key": "verify-batch-2026-09-29" } } ``` ## The hold A job holds the price of every item before it answers `queued`. With 30 inputs at 7 credits, 210 credits leave your org's available balance the moment the job is accepted, trial credits first, then paid. So the balance and the budget checks run against the whole job: the org must have `price × items` in credits, or the answer is `insufficient_credits`; if the amount is over the agent's approval threshold or its monthly budget, the answer is `approval_required` and nothing is held; if the org has never topped up, the trial limit of 50 credits an hour applies to the whole job. A job the worker never picks up costs nothing. The hold is not settled at the end. Each item settles the moment its check finishes: a pass captures that item's price, a fail releases it, a partial (web search only) captures `price × fraction`, rounded up, and releases the rest. So credits flow back into the balance item by item while the job runs, and `charged + refunded` on the finished job always equals `max_charge`. A hold outside a job is released after a few minutes if its item never settles; a running job with a fresh heartbeat is exempt from that rule, so its holds can stay for the whole run, up to the 2-hour limit. When the limit passes, every item that has not run is released with the reason `expired`, and its share of the hold is back in the balance. Items that ran before the limit keep their outcome: passed items are charged as usual. If a worker dies mid-call, the item it was calling is released with the reason `interrupted` and is never charged; it is not retried. ## How a job runs 1. **Accepted.** In one transaction: the job row, one execution per item, the hold for every item, and the inputs, encrypted with your org's own vault key. Status `queued`. If the same request arrives twice at the same instant, the second holds nothing and answers `request_in_progress`. 2. **Claimed.** The worker looks for work every 2 seconds and takes the oldest queued job. Status `running`, `started_at` is set, and the 2-hour deadline starts now, not at submission. 3. **Run.** The inputs are decrypted and the items run in index order, up to 8 at a time, through the same call, check and settle steps as a single request. With `fallback: true`, an item that fails gets one more attempt with the next-ranked tool, within `max_price ÷ items`. Every item settles as soon as it is checked. 4. **Heartbeat.** The worker stamps the job every 30 seconds. If the heartbeat is older than 2 minutes, another worker takes the job over. Items already settled are kept; an item caught mid-call by the dead worker is released as `interrupted`; the rest run as normal. 5. **Deadline.** No new item starts after 2 hours from `started_at`. Whatever has not run is released as `expired`, and the job's status becomes `expired` instead of `done`. 6. **Finished.** One receipt is written for the job. The encrypted inputs are deleted. `finished_at` is set, `progress` equals `items`, and the org gets one `job.completed` event. Arettic can cancel a running or queued job from support. Its status becomes `failed`, every item that had not run is released as `expired`, and the encrypted inputs are deleted. Items that already ran keep their outcome. A job that was already running when it was cancelled still gets a receipt for what ran. ## Job states `status` on the job answer is one of five values. `queued` and `running` mean keep polling; the other three are final and never change. Job states | status | Meaning | What the answer carries | |---|---|---| | queued | Accepted and held; no worker has started it yet. Usually seconds. | `items` (the count), `progress` 0, `started_at` and `finished_at` null. | | running | A worker is on it. Also shown while a job is being taken over after its worker died. | `progress` counts items already settled. Still no results. | | done | Every item ran and settled within the limit. | `receipt_id`, `charged`, `refunded`, `summary` and `items[]` with every outcome and result. | | expired | The 2-hour limit passed with items still waiting. Those items are `failed` with the reason `expired` and were never charged. | The same fields as `done`. `charged` covers the items that ran and passed. | | failed | Arettic cancelled the job (support). Items not yet run are released as `expired`. | The same fields as `done` when a receipt was written; otherwise the progress fields only. | ## Poll the job `GET /v1/jobs/{job_id}` with the agent key that submitted the job. The answer is small while the job runs, and carries every item once the job has its receipt. Polls count against the agent key's limit of 600 requests a minute ([Rate limits](/docs/rate-limits)); every 2 seconds, the SDKs' default, is far inside it. A job belongs to the agent that submitted it: another agent, even in the same org, gets `not_found`. **curl** ```bash curl https://api.arettic.com/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21 \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` **TypeScript** ```ts import { Arettic, AretticApiError } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); const inputs = Array.from({ length: 30 }, (_, i) => ({ email: `p${i}@acme.com` })); const bought = await arettic.execute( { tool_id: "hunter-verify-email", inputs, max_price: 210 }, { idempotencyKey: "verify-batch-2026-09-29" }, ); if (bought.status === "queued") { // One poll, if you want to show progress yourself: const now = await arettic.job(bought.job_id); console.log(now.status, now.progress, "of", bought.items); // Or let the SDK poll every 2 s for up to 10 minutes (both are options): try { const job = await arettic.waitForJob(bought.job_id, { intervalMs: 2_000, timeoutMs: 30 * 60_000 }); console.log(job.status, job.summary, job.charged?.credits, job.receipt_id); if (Array.isArray(job.items)) { for (const item of job.items) console.log(item.index, item.status, item.result ?? item.reason); } } catch (err) { // code "timeout": the job is still running; call waitForJob again later. Nothing is cancelled. if (err instanceof AretticApiError && err.code === "timeout") console.log("still running"); else throw err; } } ``` **Python** ```python from arettic import Arettic, AretticApiError client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) inputs = [{"email": f"p{i}@acme.com"} for i in range(30)] bought = client.execute( {"tool_id": "hunter-verify-email", "inputs": inputs, "max_price": 210}, idempotency_key="verify-batch-2026-09-29", ) if bought["status"] == "queued": # One poll, if you want to show progress yourself: now = client.job(bought["job_id"]) print(now["status"], now["progress"], "of", bought["items"]) # Or let the SDK poll every 2 s for up to 600 s (both are arguments): try: job = client.wait_for_job(bought["job_id"], interval=2.0, timeout=1800.0) except AretticApiError as err: if err.code != "timeout": raise print("still running") # call wait_for_job again later; nothing is cancelled else: print(job["status"], job.get("summary"), job.get("receipt_id")) for item in job["items"]: print(item["index"], item["status"], item.get("result") or item.get("reason")) ``` Over MCP the tool is `get_job`. Its only argument is `job_id`, and the result is the same JSON as the REST answer. **MCP tool call** ```json { "name": "get_job", "arguments": { "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21" } } ``` **Response (example): while it runs** ```json { "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "status": "running", "items": 30, "progress": 12, "created_at": "2026-09-29T09:12:45.118Z", "started_at": "2026-09-29T09:12:47.402Z", "finished_at": null } ``` Once the job has its receipt, `items` becomes the per-item array and the charge fields appear. Thirty verify-email inputs, 27 of them passing, shortened to three items: **Response (example): done** ```json { "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "status": "done", "items": [ { "index": 0, "status": "passed", "charged": { "credits": "7", "usd": "0.007" }, "result": { "email": "emily@acme.com", "status": "valid" } }, { "index": 1, "status": "failed", "charged": { "credits": "0", "usd": "0.000" }, "reason": "status:unknown" }, { "index": 2, "status": "passed", "charged": { "credits": "7", "usd": "0.007" }, "result": { "email": "p3@acme.com", "status": "valid" } } ], "progress": 30, "created_at": "2026-09-29T09:12:45.118Z", "started_at": "2026-09-29T09:12:47.402Z", "finished_at": "2026-09-29T09:13:21.977Z", "receipt_id": "8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f", "tool_id": "hunter-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "189", "usd": "0.189" }, "refunded": { "credits": "21", "usd": "0.021" }, "summary": { "items": 30, "passed": 27, "partial": 0, "failed": 3 } } ``` **Response (example): expired before any item ran** ```json { "job_id": "b7e1d0c9-2f3a-4b5c-8d6e-7f8a9b0c1d2e", "status": "expired", "items": [ { "index": 0, "status": "failed", "charged": { "credits": "0", "usd": "0.000" }, "reason": "expired" } ], "progress": 30, "created_at": "2026-09-29T07:00:02.511Z", "started_at": "2026-09-29T07:00:04.090Z", "finished_at": "2026-09-29T09:00:04.731Z", "receipt_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "tool_id": "hunter-verify-email", "task_type": "verify_email", "check_version": "v1", "charged": { "credits": "0", "usd": "0.000" }, "refunded": { "credits": "210", "usd": "0.210" }, "summary": { "items": 30, "passed": 0, "partial": 0, "failed": 30 } } ``` ## The job answer, field by field GET /v1/jobs/{job_id} fields | Field | When | Meaning | |---|---|---| | job_id | always | The job's UUID. | | status | always | `queued`, `running`, `done`, `expired` or `failed`. See [Job states](#states). | | items | always | While the job runs: the number of inputs. Once it has its receipt: the array of per-item outcomes, in input order. | | progress | always | How many items have settled (captured, released or expired). Counts first attempts only, never fallback attempts. Equals `items` on a finished job. | | created_at, started_at, finished_at | always | ISO 8601 timestamps. `started_at` is when a worker first claimed the job and the 2-hour limit began; both are null until then. `finished_at` is null until the job is final. | | receipt_id | finished | The one receipt for the whole job. Fetch it with `GET /v1/receipts/{receipt_id}`. See [One receipt per job](#receipt). | | tool_id, task_type, check_version | finished | The tool that ran (its slug), its task type, and the version of the pass rule that judged every item (`v1`). | | charged, refunded | finished | Totals over the items. `charged + refunded` equals `max_charge` from the queued answer. | | summary | finished | `{ items, passed, partial, failed }`. Expired and interrupted items count as `failed`. | | items[].index, status, charged | finished | The input's position, `passed`, `partial` or `failed`, and what that item cost. | | items[].result | finished | The provider's answer, on `passed` and `partial` items, while Arettic still stores it: results are kept encrypted for 7 days after the item ran, then deleted. After that the item keeps its status and charge and has no `result`. | | items[].reason | finished | On `failed` and `partial` items: a [reason code](/docs/execute#reason-codes) from the check, or `expired` (the 2-hour limit or a cancellation came first), `interrupted` (caught mid-call when a worker died), `provider_error` or `timeout`. Never charged, except a partial. | | items[].served_by, attempts, fallback_note | finished, with `fallback: true` | Which tool served the item, both attempts with their outcomes and charges, or why no fallback ran. See [Fallback](/docs/execute#fallback). | ## One receipt per job A job writes exactly one receipt, when it finishes, and never one per item. The receipt carries the job's id in `job_id`, the same `summary`, `charged` and `refunded` as the job answer, and one entry per item with its `execution_id`, the provider, the outcome (`captured`, `released` or `expired`), the reason, the input and result hashes and the check version. A fallback attempt appears as its own entry with `fallback_of` pointing at the first attempt. Fetch it with `GET https://api.arettic.com/v1/receipts/{receipt_id}`, or list the org's receipts with `GET /v1/receipts`. The item's `execution_id` on the receipt is what you need to [dispute](/docs/disputes) a charged item within 7 days. Everything on the receipt is on [Receipts](/docs/receipts). **curl** ```bash curl https://api.arettic.com/v1/receipts/8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` ## The job.completed webhook When a job reaches a final state through the worker, the org gets one `job.completed` event, delivered to every active webhook endpoint that subscribed to it (or to all events). It is sent once per job, and it is a machine event: no email goes to the owners. The status in the event is the job's final status, so a job that ran out of time arrives as `job.completed` with `status: "expired"`. The event carries counts only; fetch the job or the receipt for the items. **Webhook request (example)** ```http POST /hooks/arettic HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: Arettic-Webhooks/1.0 Arettic-Event-Id: 5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d Arettic-Event-Type: job.completed Arettic-Signature: t=1790759602,v1=4f0d2c9b8a7e6d5c4b3a2918f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4 { "id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "type": "job.completed", "created_at": "2026-09-29T09:13:22.004Z", "data": { "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "status": "done", "receipt_id": "8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f", "item_count": 30, "passed": 27, "expired": 0 } } ``` job.completed data fields | Field | Meaning | |---|---| | job_id | The job. Fetch it with `GET /v1/jobs/{job_id}` for the items. | | status | The job's final status: `done`, `expired`, or `failed` for a cancelled job that was mid-run. | | receipt_id | The job's one receipt. | | item_count | How many inputs the job had. | | passed | How many items passed their check. Partial items are not counted here. | | expired | How many items were released because the 2-hour limit passed before they ran. | The signature is `t=,v1=.">` with your endpoint's secret, and a delivery counts as made on any 2xx; otherwise it is retried with backoff for 24 hours. Creating endpoints, verifying the signature in code and the retry schedule are on [Webhooks](/docs/webhooks). Waiting on the webhook and polling the job work together: a poll after the event always shows the final state. ## Approvals, max_price, idempotency and fallback - **Approvals.** The whole job's amount (`price × items`) is compared with the agent's approval threshold and its monthly budget. Over either, the answer is `approval_required` (HTTP 202), nothing is held, and an owner is emailed. Once approved, send exactly the same tool and inputs with `approval_id`; the job is then created and held. An approved job never falls back, because an approval covers its locked tool only. See [Budgets and approvals](/docs/budgets-and-approvals). - **max_price.** A cap on the whole job, in credits. `price × items` over the cap refuses the request with `price_above_max` before anything is held. With `fallback: true`, each item may fall back to a tool priced at or under `max_price ÷ items`. `max_charge` on the queued answer is `price × items`, never more than `max_price`. - **Idempotency.** Send an `idempotency_key`; both SDKs add a UUID when you don't. The same agent sending the same key and the same request while the job runs gets `request_in_progress` (HTTP 409, retryable): only one job exists and only it is charged. Once the job has its receipt, the same key returns the finished job's items like a completed batch, with `replayed: true`. The same key with a different request is `idempotency_conflict`. - **Fallback.** `fallback: true` works inside a job exactly as in a request: an item that fails its check or hits a provider error gets one more attempt with the next-ranked tool for the task, and the finished job's item shows `served_by` and both `attempts`. `progress` counts the item once. ## Test keys and jobs A test key (`sk_test_…`) never creates a job. Every batch of up to 1,000 inputs runs at once against the mock provider for the tool's task type, with the real pass rule, and answers `status: "completed"` with `test_mode: true`, `charged` of 0, `would_have_charged` and per-item results. So a test key never sees `queued`, never gets a `job_id`, and `GET /v1/jobs/{id}` answers `not_found` for it, because it has no jobs. To rehearse the job flow itself (the `queued` answer, polling, the webhook), you need a live key and a batch over 25; the code paths for `queued` in the examples above only run live. The mock tools and what makes each pass or fail are on [Test mode](/docs/test-mode). ## Errors Submitting a job can be refused for every reason a request can: those codes are on [Execute](/docs/execute#errors). The codes below are the ones you meet on the job endpoint itself, or that behave differently for a job. Every refusal is an error envelope with `code`, `message`, `doc_url` and `retryable`; the full registry is on [Error codes](/docs/errors). Errors on jobs | code | HTTP | When | |---|---|---| | [not_found](/docs/errors#not_found) | 404 | `GET /v1/jobs/{id}`: the id is not a job UUID, the job doesn't exist, or it was submitted by a different agent (the same org is not enough). Also what a test key gets, since it has no jobs. | | [unauthenticated](/docs/errors#unauthenticated) | 401 | No `Authorization: Bearer sk_…` header, or the key was revoked. | | [invalid_input](/docs/errors#invalid_input) | 400 | `inputs` is empty or has more than 1,000 objects, or any input fails the free pre-call check. The message lists the bad items by index. Nothing is held. | | [insufficient_credits](/docs/errors#insufficient_credits) | 402 | The org's balance can't cover `price × items` for the whole job. Top up or send fewer inputs. | | [trial_limit](/docs/errors#trial_limit) | 429 | The org has never topped up and the job would take it over 50 trial credits in an hour. | | [price_above_max](/docs/errors#price_above_max) | 402 | `price × items` is above `max_price`. Raise the cap or send fewer inputs. | | [approval_required](/docs/errors#approval_required) | 202 | Not an error envelope but a `status`: the job's amount is over the agent's approval threshold or budget. Retry with `approval_id` once an owner approves; approvals expire in 24 hours. | | [request_in_progress](/docs/errors#request_in_progress) | 409 | The same `idempotency_key` and request while the job is still running, or two identical requests at the same instant. Retryable: poll the job, or send the same key again once it's finished. | **Response (example): a job that isn't yours** ```json { "error": { "code": "not_found", "message": "Job not found", "doc_url": "https://arettic.com/docs/errors#not_found", "retryable": false } } ``` The JSON Schemas for the execute request and its answers are at [https://arettic.com/schemas](/schemas). The SDK helpers `waitForJob` and `wait_for_job`, with their timeouts and errors, are on [SDKs](/docs/sdks#waiting). Updated 2026-09-29. This page as HTML: https://arettic.com/docs/jobs · Markdown: https://arettic.com/docs/jobs.md · JSON: https://arettic.com/docs/jobs.json --- # Receipts Every purchase ends in an immutable receipt: what was asked, what ran, what it cost, what was refunded. Every live `execute` request ends in one receipt. It records the tool, the fingerprint of what you sent, and for every item the provider that answered, the outcome of the published check, the version of that check, the fingerprint of the result, what was charged and what went back. A receipt never changes. A refund from an upheld dispute is added on top of it, so the receipt still shows what happened at the time. This page has every field, where to read receipts (API, SDKs, MCP, CSV, dashboard) and a full example. > Arettic is pre-launch. Keys go to design partners first; everyone else joins the [waitlist](/waitlist). The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. Test keys (`sk_test_…`) write no receipts: `receipt_id` is absent from a test response, and `GET /v1/receipts` lists live purchases only. See [Test mode](/docs/test-mode#not-in-test-mode). ## When a receipt is written A receipt is written once every item of a request has settled, that is, once each one is charged or released. For one input or a batch of up to 25, that is before the `execute` answer comes back, and the answer carries `receipt_id`. For a batch of more than 25, the [job](/docs/jobs) gets its receipt when it finishes, and `GET /v1/jobs/{job_id}` then carries `receipt_id`. A retry with the same `idempotency_key` never writes a second receipt: the replayed answer carries the first `receipt_id`. See [Idempotency](/docs/execute#idempotency). If the process dies between the last item settling and the receipt, a safety net writes the receipt a few minutes later, so a replay never hangs on a purchase that has no receipt. An item caught mid-call by the same crash is released with the reason `interrupted` and never charged. ## The receipt, field by field Money is always `{ "credits": "7", "usd": "0.007" }`: credits as a whole number in a string (1 credit is $0.001) and the same amount in dollars with three decimals. Top-level receipt fields | Field | Type | Meaning | |---|---|---| | receipt_id | UUID | The receipt's id. It is the `receipt_id` on the execute answer. | | created_at | ISO 8601 timestamp | When the receipt was written: after the last item settled. | | tool_id | string or null | The tool the request ran on, by its slug (for example `hunter-verify-email`). When a paused tool was replaced before the call (`fallback: true`), this is the replacement; the execute answer named the original in `substituted_for`. | | job_id | UUID or null | The job, when the request was a batch of more than 25. Otherwise `null`. | | idempotency_key | string or null | The `idempotency_key` you sent, or `null` if you sent none. | | request_hash | 64 hex characters or null | SHA-256 of the whole request: the tool and every input. See [Hashes](#hashes). | | check_version | string or null | The version of the pass rules the items were checked with. Currently `v1`. | | charged | money | What was captured across all items. This never changes, even after a dispute is upheld. | | refunded | money | What went back: the held credits of items that were not charged, plus every refund from an upheld dispute on this receipt. This is the one figure that grows after the receipt is written. | | summary | object | `items`, `passed`, `partial` and `failed`: one count per input, after any fallback. | | items | array | One entry per item attempt, in `index` order. A fallback attempt is a second entry with the same `index`. See below. | ## Items Each entry in `items[]` is one attempt at one input: one execution, one hold, one provider call, one check, one settlement. `reason`, `fallback_of` and `dispute` are present only when they apply. Item fields | Field | Type | Meaning | |---|---|---| | index | integer from 0 | The position of the input in `inputs` (0 for a single `input`). The original attempt and its fallback share it. | | execution_id | UUID | The attempt's id. It is the `execution_id` on a single-input answer, and what a [dispute](/docs/disputes) can name instead of `receipt_id` + `item_index`. | | tool_id | string | The tool that ran this attempt, by slug. A fallback attempt names its own tool. | | provider | string | The provider behind that tool, by slug (for example `hunter`). | | outcome | string | The settlement: `captured`, `released` or `expired`. See the table below. | | reason | string, optional | Why the item was not a pass: a [reason code](/docs/execute#reason-codes) such as `status:unknown`, `no_result`, `provider_error`, `timeout`, `interrupted`, `cancelled` or `job_time_limit`. Absent on a pass. | | charged | money | What was captured for this attempt. | | refunded | money | The rest of the hold, given back: price minus `charged`. A dispute refund is not in here; it is in `dispute.refunded` and in the receipt's top-level `refunded`. | | pricing_mode | string | `per_success` (the default: a pass is charged, a fail is not) or `per_call` (every checked call is charged, pass or fail; provider errors stay free). See [Per-call pricing](/docs/execute#per-call-pricing). | | input_hash | 64 hex characters | SHA-256 of this input. See [Hashes](#hashes). | | result_hash | 64 hex characters or null | SHA-256 of the provider's answer as it was checked. `null` when there was no answer (a provider error, a timeout, an interrupted or expired item). | | check_version | string or null | The pass-rule version this attempt was checked with (`v1`). | | fallback_of | UUID, optional | On a fallback attempt: the `execution_id` of the first attempt. See [Fallback](/docs/execute#fallback). | | settled_at | ISO 8601 timestamp or null | When the attempt was captured or released. | | dispute | object, optional | When this attempt has been disputed: `dispute_id`, `status` (`open`, `upheld` or `rejected`) and `refunded` (money; 0 until upheld). | Item outcomes | outcome | Money | When | |---|---|---| | captured | `charged` is above 0 | The result passed its check, or was a partial pass (web search, charged pro rata), or the org is on per-call pricing for the tool and the call was checked. | | released | `charged` is 0, `refunded` is the full price | The check failed, the provider errored or timed out, the checker itself threw (`check_error`), a job cancelled the item before it ran (`cancelled`), or a crash caught it mid-call (`interrupted`). | | expired | `charged` is 0, `refunded` is the full price | A job hit its 2-hour limit before this item ran (`job_time_limit`). Only on receipts with a `job_id`. | The receipt's `charged` is the sum of the items' `charged`; its `refunded` is the sum of the items' `refunded` plus the sum of every `dispute.refunded`. The CSV export adds up the same way, row by row. ## Hashes The three hashes let you prove, later, what you asked and what you got, without Arettic keeping the data. Inputs and results themselves are stored encrypted for 7 days (longer while a dispute on them is open); the hashes stay on the receipt. Every hash is SHA-256, as 64 lowercase hex characters, of the value's **canonical JSON**: object keys sorted at every level, arrays in their order, no whitespace, strings and numbers encoded as JSON. The values hashed are: - `request_hash`: the object `{ "tool": , "items": [ ] }`. A single `input` is a one-item list. The same key with a different `request_hash` is what makes a replay refuse with `idempotency_conflict`. - `input_hash`: the input object of that item, exactly as you sent it after the free pre-call check. - `result_hash`: the provider's answer after Arettic's normalisation, which is the `result` a passed item returns. For `find_email` it includes the `verification_status` Arettic's own verifier added before the check. `null` when there was no answer. To check a hash yourself, canonicalise and hash the value you hold. In Node this is the same code the API runs: **TypeScript (Node)** ```ts import { createHash } from "node:crypto"; // Canonical JSON: keys sorted at every level, no whitespace. function canonical(v: unknown): string { if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; if (v && typeof v === "object") return `{${Object.keys(v as Record) .sort() .map((k) => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`) .join(",")}}`; return JSON.stringify(v); } const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); // input_hash of the input you sent; result_hash of the result a passed item returned. console.log(sha256(canonical({ email: "jason@acme.com" }))); console.log(sha256(canonical(bought.result))); ``` **Python** ```python import hashlib import json # The same canonical form for values that came off the wire as JSON: # strings, whole numbers, booleans, null, lists and objects. def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def sha256(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() print(sha256(canonical({"email": "jason@acme.com"}))) # compare with input_hash print(sha256(canonical(bought["result"]))) # compare with result_hash ``` ## Read one receipt `GET /v1/receipts/{receipt_id}` with a live agent key. Any agent of the org can read any of the org's receipts, not only its own. An id that is not a UUID, or that belongs to another org, answers `404 not_found`; the message never says which. **curl** ```bash curl https://api.arettic.com/v1/receipts/7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); const receipt = await arettic.receipt("7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d"); console.log(receipt.charged.usd, receipt.refunded.usd, receipt.summary); for (const item of receipt.items) { console.log(item.index, item.provider, item.outcome, item.reason ?? "", item.charged.credits); } ``` **Python** ```python from arettic import Arettic client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) receipt = client.receipt("7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d") print(receipt["charged"]["usd"], receipt["refunded"]["usd"], receipt["summary"]) for item in receipt["items"]: print(item["index"], item["provider"], item["outcome"], item.get("reason", ""), item["charged"]["credits"]) ``` Over MCP the `get_receipt` tool takes `receipt_id` and returns the same JSON as text content; a refusal comes back as a tool error whose text is the error envelope. Use it with a live key: test calls write no receipts, so there is nothing of a test agent's own to read. Client configs are on [MCP server](/docs/mcp). **MCP tool call** ```json { "name": "get_receipt", "arguments": { "receipt_id": "7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d" } } ``` A full receipt for a batch of three verify-email inputs: two passed, one failed on `status:unknown`, and the last item was later disputed and upheld. Note that `charged` stays at 14 while `refunded` is the 7 not charged plus the 7 refunded by the dispute. The ids and hashes are made up. **Response (example)** ```json { "receipt_id": "7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "created_at": "2026-09-29T10:14:07.512Z", "tool_id": "hunter-verify-email", "job_id": null, "idempotency_key": "verify-batch-1", "request_hash": "9f2c1e8a7b6d5c4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f", "check_version": "v1", "charged": { "credits": "14", "usd": "0.014" }, "refunded": { "credits": "14", "usd": "0.014" }, "summary": { "items": 3, "passed": 2, "partial": 0, "failed": 1 }, "items": [ { "index": 0, "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "captured", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "pricing_mode": "per_success", "input_hash": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", "result_hash": "d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3", "check_version": "v1", "settled_at": "2026-09-29T10:14:06.201Z" }, { "index": 1, "execution_id": "9b1f0d2e-4c6a-4e8b-9f3d-2a7c5e1b8d40", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "released", "reason": "status:unknown", "charged": { "credits": "0", "usd": "0.000" }, "refunded": { "credits": "7", "usd": "0.007" }, "pricing_mode": "per_success", "input_hash": "2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a", "result_hash": "e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4", "check_version": "v1", "settled_at": "2026-09-29T10:14:06.744Z" }, { "index": 2, "execution_id": "5e8c1a7b-2d3f-4a6e-8b9c-1f2e3d4c5b6a", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "captured", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "pricing_mode": "per_success", "input_hash": "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b", "result_hash": "f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5", "check_version": "v1", "settled_at": "2026-09-29T10:14:07.030Z", "dispute": { "dispute_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "status": "upheld", "refunded": { "credits": "7", "usd": "0.007" } } } ] } ``` With `fallback: true`, an input that failed on the first tool and passed on the next appears twice, with the same `index`. Only the passing attempt is charged: **Response (example): the items of a fallback receipt** ```json "items": [ { "index": 0, "execution_id": "0d2e7f31-8a4b-4c9d-b1e6-5f7a9c3d2e10", "tool_id": "hunter-verify-email", "provider": "hunter", "outcome": "released", "reason": "status:unknown", "charged": { "credits": "0", "usd": "0.000" }, "refunded": { "credits": "7", "usd": "0.007" }, "pricing_mode": "per_success", "input_hash": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", "result_hash": "e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4", "check_version": "v1", "settled_at": "2026-09-29T11:02:15.118Z" }, { "index": 0, "execution_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21", "tool_id": "zerobounce-verify-email", "provider": "zerobounce", "outcome": "captured", "charged": { "credits": "6", "usd": "0.006" }, "refunded": { "credits": "0", "usd": "0.000" }, "pricing_mode": "per_success", "input_hash": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", "result_hash": "0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b", "check_version": "v1", "fallback_of": "0d2e7f31-8a4b-4c9d-b1e6-5f7a9c3d2e10", "settled_at": "2026-09-29T11:02:16.402Z" } ] ``` ## List receipts `GET /v1/receipts` with a live agent key lists the org's receipts, newest first, one line per receipt. It is the same list the dashboard's receipt explorer shows. Filters go in the query string; all are optional. Query parameters of GET /v1/receipts | Parameter | Format | Meaning | |---|---|---| | from | YYYY-MM-DD | First day to include (UTC). Without it, 30 days before `to` (or before now). | | to | YYYY-MM-DD | Last day to include, inclusive (UTC). Without it, now. | | agent_id | agent UUID | Only this agent's receipts. A value that is not a UUID is ignored. | | tool_id | tool slug | Only receipts for this tool, for example `hunter-verify-email`. | | before | the `next` value of the previous page | Cursor: receipts older than this timestamp. Anything else is `invalid_input`. | | limit | 1 to 200 | Receipts per page. The default is 50; values outside the range are clamped. | The range may cover at most 366 days, and `from` must be before `to`; otherwise the answer is `400 invalid_input`. Each page carries `next`: pass it as `before` for the next page, and stop when it is `null`. **curl** ```bash curl "https://api.arettic.com/v1/receipts?from=2026-09-01&to=2026-09-29&tool_id=hunter-verify-email&limit=50" \ -H "Authorization: Bearer $ARETTIC_API_KEY" ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); // Every receipt for one tool in September, page by page. const query = { from: "2026-09-01", to: "2026-09-30", toolId: "hunter-verify-email", limit: 200 }; let page = await arettic.receipts(query); while (true) { for (const r of page.receipts) console.log(r.created_at, r.agent_name, r.charged.usd, r.net.usd); if (!page.next) break; page = await arettic.receipts({ ...query, before: page.next }); } ``` **Python** ```python from arettic import Arettic client = Arettic() # `from` is a Python keyword, so the SDK takes from_ and sends it as from. query = {"from_": "2026-09-01", "to": "2026-09-30", "tool_id": "hunter-verify-email", "limit": 200} page = client.receipts(**query) while True: for r in page["receipts"]: print(r["created_at"], r["agent_name"], r["charged"]["usd"], r["net"]["usd"]) if not page["next"]: break page = client.receipts(**query, before=page["next"]) ``` **Response (example)** ```json { "receipts": [ { "receipt_id": "7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "created_at": "2026-09-29T10:14:07.512Z", "agent_id": "4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a", "agent_name": "Research, EU", "tool_id": "hunter-verify-email", "job_id": null, "summary": { "items": 3, "passed": 2, "partial": 0, "failed": 1 }, "charged": { "credits": "14", "usd": "0.014" }, "dispute_refunded": { "credits": "7", "usd": "0.007" }, "net": { "credits": "7", "usd": "0.007" }, "open_disputes": 0 } ], "next": null } ``` Fields of a receipt in the list | Field | Meaning | |---|---| | receipt_id, created_at, tool_id, job_id, summary, charged | As on the receipt. | | agent_id, agent_name | The agent that bought. | | dispute_refunded | Money refunded by upheld disputes on this receipt. | | net | `charged` minus `dispute_refunded`: what the receipt cost in the end. Credits released at settlement are not part of either. | | open_disputes | Disputes on this receipt not yet decided. | | next | The cursor for the next page, or `null` on the last one. | ## The org endpoints The same reads exist under `/v1/orgs/{orgId}/…` for people and for [org keys](/docs/authentication#org-keys) (`ok_…`, read scope is enough), with the member role or above. They take the same filters and return the same shapes. The CSV export exists only here. Receipt endpoints | Endpoint | Key | Returns | |---|---|---| | GET /v1/receipts/{id} | agent key | One receipt. | | GET /v1/receipts | agent key | The org's receipts, newest first, with filters and paging. | | GET /v1/orgs/{orgId}/receipts/{id} | session or org key | One receipt. | | GET /v1/orgs/{orgId}/receipts | session or org key | The same list, same filters. | | GET /v1/orgs/{orgId}/exports/receipts.csv | session or org key | Every item attempt in the range as CSV; filters `from` and `to`. See [CSV export](#csv-export). | **curl** ```bash curl "https://api.arettic.com/v1/orgs/$ARETTIC_ORG_ID/receipts?limit=200" \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" curl https://api.arettic.com/v1/orgs/$ARETTIC_ORG_ID/receipts/7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" curl "https://api.arettic.com/v1/orgs/$ARETTIC_ORG_ID/exports/receipts.csv?from=2026-09-01&to=2026-09-30" \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -o arettic-receipts.csv ``` **TypeScript** ```ts import { AretticOrg } from "@arettic/sdk"; const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, orgId: "your-org-id" }); const page = await org.receipts.list({ from: "2026-09-01", to: "2026-09-30", limit: 200 }); const receipt = await org.receipts.get("7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d"); const csv = await org.receipts.exportCsv({ from: "2026-09-01", to: "2026-09-30" }); // a string ``` **Python** ```python import os from arettic import AretticOrg org = AretticOrg(os.environ["ARETTIC_ORG_KEY"], "your-org-id") page = org.receipts.list(from_="2026-09-01", to="2026-09-30", limit=200) receipt = org.receipts.get("7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d") csv = org.receipts.export_csv(from_="2026-09-01", to="2026-09-30") # a string ``` ## CSV export `GET /v1/orgs/{orgId}/exports/receipts.csv` returns one row per item attempt (a fallback is its own row), oldest receipt first, for the same `from` and `to` range as the list (default the last 30 days, at most 366), up to 100,000 rows. The body is `text/csv; charset=utf-8` with CRLF line endings, sent as an attachment named `arettic-receipts.csv`. Cells with commas, quotes or line breaks are quoted; a cell that starts with `=`, `+`, `-`, `@` or a tab gets a leading `'` so a spreadsheet never runs it as a formula. Per receipt, the `charged_credits` rows add up to the receipt's `charged`, and the `net_credits` rows to the list's `net`. CSV columns, in order | Column | Meaning | |---|---| | receipt_id | The receipt. | | receipt_created_at | When the receipt was written, ISO 8601. | | agent_id | The agent that bought. | | agent_name | Its name. | | tool_id | The tool that ran this attempt, by slug. | | provider | Its provider, by slug. | | item_index | The input's position; the original and its fallback share it. | | execution_id | The attempt's id. | | attempt | `original` or `fallback`. | | outcome | `captured`, `released` or `expired`. | | reason | The reason code, or empty on a pass. | | price_credits | The price held for the attempt. | | charged_credits | What was captured. | | charged_usd | The same in dollars, three decimals. | | not_charged_credits | `price_credits` minus `charged_credits`: the hold given back. | | dispute_status | `open`, `upheld`, `rejected`, or empty when never disputed. | | dispute_refunded_credits | Credits refunded by an upheld dispute, else 0. | | net_credits | `charged_credits` minus `dispute_refunded_credits`. | | net_usd | The same in dollars. | | input_hash | SHA-256 of the input. | | result_hash | SHA-256 of the result, or empty. | | check_version | The pass-rule version (`v1`). | | settled_at | When the attempt settled, ISO 8601. | **Response (example)** ```text receipt_id,receipt_created_at,agent_id,agent_name,tool_id,provider,item_index,execution_id,attempt,outcome,reason,price_credits,charged_credits,charged_usd,not_charged_credits,dispute_status,dispute_refunded_credits,net_credits,net_usd,input_hash,result_hash,check_version,settled_at 7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d,2026-09-29T10:14:07.512Z,4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a,"Research, EU",hunter-verify-email,hunter,0,c02576cf-b3ce-4b0f-a30c-6e8aad4328ce,original,captured,,7,7,0.007,0,,0,7,0.007,1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809,d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3,v1,2026-09-29T10:14:06.201Z 7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d,2026-09-29T10:14:07.512Z,4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a,"Research, EU",hunter-verify-email,hunter,1,9b1f0d2e-4c6a-4e8b-9f3d-2a7c5e1b8d40,original,released,status:unknown,7,0,0.000,7,,0,0,0.000,2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a,e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4,v1,2026-09-29T10:14:06.744Z 7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d,2026-09-29T10:14:07.512Z,4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a,"Research, EU",hunter-verify-email,hunter,2,5e8c1a7b-2d3f-4a6e-8b9c-1f2e3d4c5b6a,original,captured,,7,7,0.007,0,upheld,7,0,0.000,3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b,f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5,v1,2026-09-29T10:14:07.030Z ``` ## The dashboard Signed-in members see the same data at [https://arettic.com/app/receipts](/app/receipts). The explorer lists receipts newest first with the time (UTC), agent, tool, how many items were charged out of how many, what was charged and the net after dispute refunds, with a pill for open disputes. It filters by date, agent and tool, shows the last 30 days by default, and pages with an "Older receipts" link. "Download CSV" gives the export above for the same date range. Opening a receipt shows every item with its provider, outcome, reason and charge, and a "Dispute" form on any charged item for 7 days after it settled. Filing and following disputes is on [Disputes](/docs/disputes). ## Immutability and disputes A receipt and its items are written once, after settlement, and never updated: `charged`, every item's `outcome`, `charged`, `refunded`, the hashes and `check_version` stay as they were. Two things are layered on top when a dispute is filed and decided: - The item gains a `dispute` object with the dispute's `status` and its `refunded` amount, which is 0 until the dispute is upheld. - The receipt's top-level `refunded` grows by every upheld dispute's refund. In the list, `dispute_refunded` carries the same amount and `net` is `charged` minus it. A charged item can be disputed for 7 days after it settled; Arettic decides within 48 hours against the stored copy of the result, and a dispute not decided in time is upheld automatically. An upheld dispute returns the item's charge to the buckets it came from (trial or paid). The receipt's `charged` is not reduced, so a receipt read a year later still says what was paid on the day. ## JSON Schema The receipt's JSON Schema (draft 2020-12) is published at [https://api.arettic.com/schemas/receipt.json](https://api.arettic.com/schemas/receipt.json) and listed with the other schemas at [https://arettic.com/schemas](/schemas). Its version is bumped only when the shape changes in a way a validator would notice. Fields marked optional above (`reason`, `fallback_of`, `dispute`) are absent, not `null`, when they do not apply. ## Errors A refusal comes back as `{ "error": { "code", "message", "doc_url", "retryable" } }`; the SDKs raise it as `AretticApiError` with the same fields. The codes you can get from the receipt endpoints: Error codes on the receipt endpoints | code | HTTP | When | |---|---|---| | [not_found](/docs/errors#not_found) | 404 | The receipt id is not a UUID, or no receipt with that id belongs to your org. On the org endpoints, also when the person or key has no membership in that org. | | [invalid_input](/docs/errors#invalid_input) | 400 | `from` or `to` is not `YYYY-MM-DD`, `from` is not before `to`, the range is over 366 days, or `before` is not a valid cursor. | | [unauthenticated](/docs/errors#unauthenticated) | 401 | No key, a revoked key, or a disabled agent. A test key can read receipts but sees none of its own, since test calls write none. | | [forbidden](/docs/errors#forbidden) | 403 | An org key used on another org's `/v1/orgs/{orgId}/…` path. | | [rate_limited](/docs/errors#rate_limited) | 429 | Too many requests from this agent. Wait the `Retry-After` seconds and send it again; see [Rate limits](/docs/rate-limits). | ## Where next [Disputes](/docs/disputes) to challenge a charged item. [Execute](/docs/execute) for the answer a purchase returns and every reason code. [Batches and jobs](/docs/jobs) for receipts that come from a job. [Org API](/docs/org-api) for everything an org key can read next to receipts. [Error codes](/docs/errors) for the full registry. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/receipts · Markdown: https://arettic.com/docs/receipts.md · JSON: https://arettic.com/docs/receipts.json --- # Disputes Think a charged result was wrong? Dispute it within 7 days; we decide within 48 hours against the stored result. You pay only for results that pass their published check. A check can still be wrong: an email that was called deliverable bounces, a company record is out of date, an item was charged twice. A dispute is how you tell us. You point at the charged item, say what is wrong, and we compare your claim with the stored copy of the result. If we agree, the charge goes back to your balance and the tool's score takes the hit. This page covers who can dispute, the request, what we compare against, the decision, the 48-hour deadline, how to follow a dispute, the notifications, and the errors. > Arettic is pre-launch. Keys go to design partners first; everyone else joins the [waitlist](/waitlist). The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. Disputes need a live key: test purchases cost nothing, so there is nothing to dispute. ## What can be disputed, and by whom An item can be disputed when all of these hold: - **It was charged.** Only items with a `charged` above 0 on the [receipt](/docs/receipts) qualify: a `passed` result, or a `partial` one charged pro rata. A failed check, a provider error or an expired hold cost you nothing, so there is nothing to dispute. The API answers [not_disputable](/docs/errors#not_disputable). - **It was bought with a live key.** Test keys (`sk_test_…`) buy from mock tools for free and write no receipt. A test key that calls `POST /v1/disputes` gets [invalid_input](/docs/errors#invalid_input): "Test-mode purchases are free; there's nothing to dispute". - **It settled less than 7 days ago.** The clock starts at the item's `settled_at` on the receipt. Later than that, the API answers [dispute_window_closed](/docs/errors#dispute_window_closed). - **It has no dispute yet.** One dispute per item, ever. A second attempt answers [conflict](/docs/errors#conflict): "This item already has a dispute". - **Your org has fewer than 100 open disputes.** At 100 the API answers [rate_limited](/docs/errors#rate_limited) until some are decided. Four places can file one, and they all create the same dispute: Ways to open a dispute | From | How | Who | |---|---|---| | Agent key | `POST https://api.arettic.com/v1/disputes` | Any live agent of the org, for any item on the org's receipts. | | Org API | `POST https://api.arettic.com/v1/orgs/{orgId}/disputes` | A signed-in member of any role, or an org key (`ok_…`) with write scope. A read-only org key gets [forbidden](/docs/errors#forbidden). | | Dashboard | Receipts, open the receipt, **Dispute** on the item's row (`https://arettic.com/app/receipts/{receiptId}`) | Any member. The button only shows on items that qualify. | | MCP | The `open_dispute` tool | Any live agent. Same arguments as the agent key request. | ## The request Name the item either by its receipt and position, or by its execution. Then say why. Fields of POST /v1/disputes | Field | Type | Required | Notes | |---|---|---|---| | receipt_id | UUID string | unless execution_id is sent | The receipt the item is on, from the execute response or the [receipt](/docs/receipts). | | item_index | integer, 0 or more | no | The item's position on the receipt (`items[].index`). Default 0, which is the only item of a single-input purchase. | | execution_id | UUID string | instead of receipt_id | The item's `execution_id` from the execute response or the receipt. When it is sent, `receipt_id` and `item_index` are ignored. | | reason | string | yes | One of the five reasons below. | | evidence | string, up to 4,000 characters | when reason is `other` | What is wrong, in your words. Trimmed; anything past 4,000 characters is dropped. The reviewer reads it, and it is shown on the dispute. | Reasons | reason | Meaning | Label in the dashboard | |---|---|---| | wrong_result | The result passed its check but is not true. | The result is wrong | | invalid_result | The result does not work: the email bounced, the number is dead. | The result doesn't work | | stale_result | The result was true once, not now. | The result is out of date | | duplicate_charge | You were charged twice for the same thing. | Charged twice | | other | Anything else. `evidence` is required. | Something else | If the item was served by a fallback tool (the receipt's item has `fallback_of`), the dispute lands on the execution that was charged, and the dispute's `tool_id` names that tool. Other agents' purchases count too: any agent of the org can dispute any item on the org's receipts. Another org's receipt answers [not_found](/docs/errors#not_found), the same as an `item_index` that does not exist. ## Opening a dispute Item 1 of a two-email purchase was called deliverable and bounced. The same call in curl, TypeScript, Python and over MCP: **curl** ```bash curl https://api.arettic.com/v1/disputes \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "item_index": 1, "reason": "invalid_result", "evidence": "Bounced when we sent to it" }' ``` **curl, by execution_id** ```bash curl https://api.arettic.com/v1/disputes \ -H "Authorization: Bearer $ARETTIC_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "reason": "invalid_result" }' ``` **TypeScript** ```ts import { Arettic, AretticApiError } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); try { const dispute = await arettic.openDispute({ receipt_id: "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", item_index: 1, // or { execution_id: "…" } instead of receipt_id + item_index reason: "invalid_result", evidence: "Bounced when we sent to it", }); console.log(dispute.dispute_id, dispute.status, dispute.decide_by); // "open", 48 hours from now } catch (err) { if (err instanceof AretticApiError) { // "not_disputable", "dispute_window_closed", "conflict", "not_found", "invalid_input"… console.error(err.status, err.code, err.message); } throw err; } ``` **Python** ```python from arettic import Arettic, AretticApiError client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set) try: dispute = client.open_dispute( receipt_id="67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", item_index=1, # or execution_id="…" instead of receipt_id + item_index reason="invalid_result", evidence="Bounced when we sent to it", ) except AretticApiError as err: # "not_disputable", "dispute_window_closed", "conflict", "not_found", "invalid_input"... print(err.status, err.code, err.message) raise print(dispute["dispute_id"], dispute["status"], dispute["decide_by"]) # "open", 48 hours from now ``` **MCP tool call** ```json { "name": "open_dispute", "arguments": { "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "item_index": 1, "reason": "invalid_result", "evidence": "Bounced when we sent to it" } } ``` Over MCP, `open_dispute` takes `receipt_id` and `item_index` only (no `execution_id`); `item_index` defaults to 0. The tool result is the same JSON as the REST response, as text content. The API answers `201 Created` with the dispute: **Response (example)** ```json { "dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30", "status": "open", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "item_index": 1, "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "tool_id": "hunter-verify-email", "reason": "invalid_result", "evidence": "Bounced when we sent to it", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "0", "usd": "0.000" }, "opened_at": "2026-09-29T10:12:03.417Z", "decide_by": "2026-10-01T10:12:03.417Z", "decided_at": null } ``` From the org API the request body is the same, at `POST /v1/orgs/{orgId}/disputes`. With the SDKs: `new AretticOrg({ orgId }).disputes.create({ … })` and `AretticOrg(org_id=…).disputes.create(…)`. See [Org API](/docs/org-api). ## The dispute object Every dispute endpoint returns the same object. Money is `{ "credits": "7", "usd": "0.007" }`, as everywhere else in the API. Times are ISO 8601 in UTC. Fields of a dispute | Field | Meaning | |---|---| | dispute_id | The dispute's UUID. Use it with `GET /v1/disputes/{id}`. | | status | `open` until we decide, then `upheld` (refunded) or `rejected` (the charge stands). It never changes again after that. | | receipt_id | The receipt the item is on. | | item_index | The item's position on that receipt. | | execution_id | The charged execution behind the item. After a fallback, the one that served it. | | tool_id | The tool that produced the disputed result. | | reason | The reason you gave. | | evidence | Your evidence, or `null`. | | charged | What the item cost you. This is the most an upheld dispute refunds. | | refunded | What has been refunded for this dispute: 0 while open or rejected, the item's charge once upheld. | | opened_at | When the dispute was filed. | | decide_by | 48 hours after `opened_at`. Our deadline; past it the dispute is upheld automatically. | | decided_at | When it was decided, or `null` while open. | | decision_note | Present once decided and a note was written. Every rejection has one, because we must say why. An automatic uphold says "Upheld automatically: not decided within 48 hours". | ## What we compare against Every live purchase's input and result are stored encrypted with your org's own key, for 7 days, then hard-deleted. That copy is what a dispute is decided against. Opening a dispute puts a hold on the item's copy so it outlives the 7 days: it is kept until the dispute is decided, and at most 48 hours beyond the usual 7 days. Once decided, the hold lifts and the normal retention applies again. The reviewer sees your reason and evidence, the stored input and result, the result's hash, the check and its version, and the reason the check recorded. Every look at a stored copy is logged with who looked, which dispute it was for, and whether the copy was still there. The copy is not shared with the provider or anyone outside Arettic. If you asked for your org's data to be deleted before the dispute was reviewed, the copy is gone, and the reviewer is told so instead of seeing a result. [Data handling](/docs/data-handling) has the retention rules in full. ## The decision The two outcomes | status | What happens | |---|---| | upheld | The item's charge goes back to your balance, to the same paid or trial credits it was taken from, as a `refund` entry on the ledger that references the dispute. The dispute's `refunded` becomes the item's `charged`, the receipt's `refunded` total grows by the same amount, and the item's `dispute.refunded` on the receipt shows it. The upheld dispute counts against the tool's score. | | rejected | The charge stands. The reviewer has to write a `decision_note` saying why, and you see it on the dispute, in the dashboard and in the email. Nothing changes on the receipt except the item's `dispute.status`. | A dispute is decided once. Receipts never change; a refund is added on top, so the receipt's `charged` still says what you paid at the time and `refunded` says what came back. If the same item was somehow refunded already, the uphold is refused rather than paid twice. ## 48 hours, then automatic uphold We decide within 48 hours of `opened_at`; `decide_by` is that moment. An hourly job upholds every open dispute past its `decide_by`, with the note "Upheld automatically: not decided within 48 hours", and refunds it like any other uphold. You never wait on us longer than 48 hours, and you never need to chase a dispute: if nobody looked at it, you get the refund. ## Following a dispute A dispute shows up in four places as soon as it is filed: - `GET https://api.arettic.com/v1/disputes/{id}` with any agent key of the org: the dispute object above. - `GET https://api.arettic.com/v1/orgs/{orgId}/disputes` with a session or an org key: `{ "disputes": [ … ] }`, open ones first, newest first, up to 500. Add `?status=open`, `upheld` or `rejected` to filter. This list is not available to agent keys. - The receipt: the item gets a `dispute` field with `dispute_id`, `status` and `refunded`, on `GET /v1/receipts/{id}` and the org receipt endpoints. Items without a dispute have no `dispute` field. - The dashboard: `https://arettic.com/app/disputes` lists every dispute with its reason, charge, status, deadline, decision time, refund and decision note, with filters for open, upheld and rejected. **curl** ```bash # One dispute, with an agent key curl https://api.arettic.com/v1/disputes/a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30 \ -H "Authorization: Bearer $ARETTIC_API_KEY" # The org's open disputes, with an org key curl "https://api.arettic.com/v1/orgs/0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87/disputes?status=open" \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" ``` **TypeScript** ```ts import { Arettic, AretticOrg } from "@arettic/sdk"; const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY }); const dispute = await arettic.dispute("a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30"); if (dispute.status === "upheld") console.log("refunded", dispute.refunded.credits); if (dispute.status === "rejected") console.log("kept, because:", dispute.decision_note); // The org's list needs an org key (ok_…), not an agent key. const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, orgId: "0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87" }); const open = await org.disputes.list({ status: "open" }); console.log(open.length, "waiting for a decision"); ``` **Python** ```python import os from arettic import Arettic, AretticOrg client = Arettic() dispute = client.dispute("a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30") if dispute["status"] == "upheld": print("refunded", dispute["refunded"]["credits"]) elif dispute["status"] == "rejected": print("kept, because:", dispute.get("decision_note")) # The org's list needs an org key (ok_...), not an agent key. org = AretticOrg(api_key=os.environ["ARETTIC_ORG_KEY"], org_id="0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87") open_disputes = org.disputes.list(status="open")["disputes"] print(len(open_disputes), "waiting for a decision") ``` **Response (example)** ```json { "dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30", "status": "upheld", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "item_index": 1, "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "tool_id": "hunter-verify-email", "reason": "invalid_result", "evidence": "Bounced when we sent to it", "charged": { "credits": "7", "usd": "0.007" }, "refunded": { "credits": "7", "usd": "0.007" }, "opened_at": "2026-09-29T10:12:03.417Z", "decide_by": "2026-10-01T10:12:03.417Z", "decided_at": "2026-09-30T08:40:19.052Z" } ``` There is no MCP tool for reading a dispute. An agent on MCP can call `get_receipt` and read the item's `dispute` field instead. ## When it is decided A decision, by a person or by the 48-hour job, sends one `dispute.decided` event. It goes to every [webhook](/docs/webhooks) of the org that subscribes to it (or to all events), signed with the endpoint's secret, and as an email to the org's owners. Nothing is sent when a dispute is opened. **Webhook body (example)** ```json { "id": "5b2c8e1f-7a3d-4f9e-b6c0-1d4a2e8f7c93", "type": "dispute.decided", "created_at": "2026-09-30T08:40:19.052Z", "data": { "dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30", "receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936", "item_index": 1, "decision": "upheld", "refunded_credits": "7", "note": null } } ``` `decision` is `upheld` or `rejected`; `refunded_credits` is a whole number of credits as a string, `"0"` on a rejection; `note` is the decision note or `null`. The email's subject is "Your dispute was upheld" or "Your dispute was rejected", and its body names the receipt and item and, on an uphold, the credits refunded. Poll `GET /v1/disputes/{id}` if you would rather not run a webhook: the status changes at the same moment. ## What an upheld dispute does to a tool's score Disputes are the D input of every tool's [score](/docs/scores). For each tool and region, at the weekly recompute, D is the number of disputes upheld in the last 28 days divided by the number of live results that passed (or partially passed) in the last 28 days. The score loses `min(20, 500 × D)` points: one upheld dispute per 100 passed results costs 5 points, and one per 25 or worse costs the maximum 20. Rejected disputes do not count. A score moves at most 10 points a week, so a wave of upheld disputes shows over a few weeks unless the tool is paused, and D is published next to the score with the other inputs. This is why we review disputes rather than refund on request: an upheld dispute is a signal to every buyer of that tool, so it has to be right. ## Test mode > A test key cannot dispute. Test purchases run against mock providers, cost nothing and write no receipt, so `POST /v1/disputes` and the `open_dispute` tool answer `400` [invalid_input](/docs/errors#invalid_input) with "Test-mode purchases are free; there's nothing to dispute". Use a live key and a charged item to try the flow end to end. See [Test mode](/docs/test-mode). ## Errors Every refusal is `{ "error": { "code", "message", "doc_url", "retryable" } }`, and the SDKs raise it as `AretticApiError` with the same fields. The codes a dispute request can get: Error codes on the dispute endpoints | Code | HTTP | When | |---|---|---| | [invalid_input](/docs/errors#invalid_input) | 400 | A test key filed; `reason` is not one of the five; `reason` is `other` without `evidence`; neither a valid `execution_id` nor a valid `receipt_id` with a whole-number `item_index` was sent. The message names the problem. | | [not_found](/docs/errors#not_found) | 404 | No item with that `receipt_id` and `item_index` (or that `execution_id`) on your org's receipts, or, on a GET, no dispute with that id in your org. | | [not_disputable](/docs/errors#not_disputable) | 409 | The item was not charged: nothing to refund. | | [dispute_window_closed](/docs/errors#dispute_window_closed) | 409 | The item settled more than 7 days ago. | | [conflict](/docs/errors#conflict) | 409 | The item already has a dispute. | | [rate_limited](/docs/errors#rate_limited) | 429 | Your org already has 100 open disputes. Wait for decisions. | | [unauthenticated](/docs/errors#unauthenticated) | 401 | No key, or a revoked one. | | [forbidden](/docs/errors#forbidden) | 403 | A read-only org key tried to file, or an org key was used on another org's path. | **Response (example)** ```json { "error": { "code": "dispute_window_closed", "message": "Disputes must be opened within 7 days of the purchase", "doc_url": "https://arettic.com/docs/errors#dispute_window_closed", "retryable": false } } ``` ## See also - [Receipts](/docs/receipts): where `receipt_id`, `item_index`, `execution_id`, `settled_at` and the item's `dispute` field live. - [Scores](/docs/scores): the formula, and D next to the other five inputs. - [Webhooks](/docs/webhooks): subscribing to `dispute.decided`, the signature and retries. - [Data handling](/docs/data-handling): the stored copy, its 7 days, and the hold a dispute puts on it. - [Error codes](/docs/errors): every code, with its status and fix. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/disputes · Markdown: https://arettic.com/docs/disputes.md · JSON: https://arettic.com/docs/disputes.json --- # Budgets and approvals Per-agent budgets and approval thresholds, enforced on our side: what happens when a purchase needs a person. An agent spends your money, so every agent has limits it can't change: a monthly budget and an approval threshold. Both are checked on our side before anything is held, on every request. Going over either doesn't fail the purchase: it asks an owner, and the agent carries on once they say yes. ## The limits Per-agent limits | Limit | Default | What it does | |---|---|---| | `monthly_budget_credits` | 50,000 credits ($50) per UTC calendar month | A live purchase that would take the agent's spend this month over its budget needs approval (`over_budget`). At 80% the owners get a `budget.80pct` event and email. | | `approval_threshold_credits` | 20,000 credits ($20) | A single request whose price × items is above it needs approval (`over_threshold`). `null` means never ask. The dashboard offers $5, $20, $50 or never. | | `ip_allowlist` | empty (any IP) | IP addresses or CIDR ranges the key may be used from. Anything else gets `403 ip_not_allowed`. | Set them on the Agents page, or with `PATCH https://api.arettic.com/v1/orgs/{orgId}/agents/{agentId}` (a member, or a write org key). An agent key can read its own limits (`GET /v1/agent`, `GET /v1/balance`) but never change them. **curl** ```bash curl -X PATCH https://api.arettic.com/v1/orgs/$ORG_ID/agents/$AGENT_ID \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "monthly_budget_credits": 200000, "approval_threshold_credits": 50000 }' ``` ## What is checked, in order 1. **Balance.** The org can't pay for price × items: declined, `402 insufficient_credits`. No approval can fix that; top up. 2. **Trial limit.** An org that has never topped up can spend at most 50 trial credits an hour: declined, `429 trial_limit`. 3. **Approval threshold,** then **monthly budget**: over either, the request becomes an approval (below). The hold step checks the budget again under a lock, so two requests sent at the same moment can't both slip under it. ## When a purchase needs approval The request is not run and nothing is held. The answer is HTTP 202: **Response** ```json { "status": "approval_required", "approval_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "reason": "over_threshold", "amount": { "credits": "24000", "usd": "24.000" }, "expires_at": "2026-10-01T11:10:00.000Z", "message": "This is above the agent's approval threshold. An owner has been asked to approve it; retry with approval_id once approved." } ``` 1. Every owner gets an email with a one-click link to approve or decline, no sign-in needed. They can also decide on the Approvals page, or with `POST /v1/orgs/{orgId}/approvals/{id}/decision` and `{ "decision": "approve" }` or `"reject"` (optional `note`). Webhook endpoints get `approval.requested` and then `approval.decided`. 2. The agent checks `GET /v1/approvals/{id}`: `status` is `pending`, `approved`, `rejected`, `expired` or `used`, with `reason`, `agent`, `tool`, `items`, `amount`, `expires_at`, `decided_at` and `decision_note`. 3. Once `approved`, the agent sends **the same request again** with `approval_id`. It runs without the threshold and budget checks, for exactly the tool, inputs and amount that were approved. - Approvals expire after 24 hours; using one after that gets `410 approval_expired`. Send the request again for a new one. - A declined request gets `403 approval_rejected`. - Changing the tool or the inputs, or a price that went up since, gets `409 approval_mismatch`. So does using an approval twice, or another agent's. - Sending the same request again while its approval is pending returns the same approval: owners aren't emailed twice. **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic(); const request = { tool_id: "peopledatalabs-enrich-company", inputs }; let result = await arettic.execute(request); if (result.status === "approval_required") { const approval = await arettic.waitForApproval(result.approval_id); // polls every 5 s, up to 24 h if (approval.status !== "approved") throw new Error(`Not approved: ${approval.status}`); result = await arettic.execute({ ...request, approval_id: result.approval_id }); } ``` **Python** ```python import time from arettic import Arettic client = Arettic() request = {"tool_id": "peopledatalabs-enrich-company", "inputs": inputs} result = client.execute(request) if result["status"] == "approval_required": approval_id = result["approval_id"] while (approval := client.approval(approval_id))["status"] == "pending": time.sleep(5) if approval["status"] != "approved": raise RuntimeError(f"Not approved: {approval['status']}") result = client.execute(request, approval_id=approval_id) ``` Over MCP, `execute` returns the same `approval_required` answer and `get_approval` checks it; see [MCP](/docs/mcp). ## Other guards - **Velocity alerts.** If an agent spends more than 3× its usual hourly rate in an hour (and at least 1,000 credits, $1), the owners get an email, at most once a day per agent. - **Revoke at once.** Revoking an agent's key on the Agents page (or `POST /v1/orgs/{orgId}/agents/{agentId}/revoke-key`) stops it on the next request. Rotating issues a new key and revokes the old one in the same step. - **Plan limits.** Each plan has a number of agents and seats; going past it gets `402 plan_limit`, with a link to upgrade in `message`. Agents and seats per plan | Plan | Agents | Seats | |---|---|---| | Pay as you go | 2 | 1 | | Pro | 10 | 5 | | Max | no limit | no limit | Updated 2026-09-30. This page as HTML: https://arettic.com/docs/budgets-and-approvals · Markdown: https://arettic.com/docs/budgets-and-approvals.md · JSON: https://arettic.com/docs/budgets-and-approvals.json --- # Pass rules Six task types, one published check each: what passes, what fails, and what is refunded. Every result bought through Arettic is checked before you pay for it. The check is a pass rule: one per task type, published here, versioned, and run on every live and test result the same way. A pass is charged. A fail is refunded in full and its data is withheld. Only `web_search` can be partial, and a partial is charged pro rata. This page has the rules, the free input check that runs before any provider is called, what each rule does to a result field by field, the reason codes, how versions change, and how to read the rules from the API. > Arettic is pre-launch. Keys go to design partners first; everyone else joins the [waitlist](/waitlist). The `@arettic/sdk` and `@arettic/mcp` packages (npm) and the `arettic` package (PyPI) are published at launch. A test key (`sk_test_…`) runs these same rules on mock results and charges nothing; see [Test mode](/docs/test-mode). ## The rules Each rule is named `task_type@version`, for example `find_email@v1`. That name is the `pass_rule` on every tool in [recommend](/docs/recommend) and `GET /v1/tools`, and its version is the `check_version` on every execute answer and every receipt. This table is generated from the same published constants the API serves at `GET /v1/task-types` and `GET /v1/formula`, so it cannot drift from what the API does. The published pass rules | task_type | Used for | Version | Passes if | Partial or failed | |---|---|---|---|---| | find_email | Outbound prospecting, recruiting outreach, partner sourcing | v1 | An email is returned and its verification status is valid (not catch-all or unknown) | Catch-all counts as a fail and is refunded | | verify_email | Cleaning a list before a campaign, sign-up and CRM hygiene | v1 | The verifier returns a definitive status (valid or invalid) | Unknown or timeout counts as a fail | | enrich_company | Account research, lead scoring and routing, CRM enrichment | v1 | The record matches the requested domain (or name + country) and has name, domain, employee range and industry | Missing required fields counts as a fail | | enrich_person | Lead qualification, contact research, candidate and investor research | v1 | Name (fuzzy ≥ 0.9) and company domain (exact) match the input; a title and one contact field are present | A match without a contact field counts as a fail | | web_search | Market and competitor research, news monitoring, building target lists | v1 | At least N results (5 by default) with valid, non-duplicate URLs | Fewer than N is charged pro rata | | extract_url | Reading pricing pages, docs, job posts and filings into an agent | v1 | HTTP 200 with at least 200 characters of main content, not a block or captcha page | A blocked page counts as a fail | The rule is a pure function of your input and the provider's result. The same result always gets the same verdict, whichever provider produced it and whoever bought it. Nothing about a tool's score, price or provider changes the verdict. ## Where the check sits in a purchase 1. **Input check, free.** Every input is checked for syntax, then domains are looked up in DNS and URLs are checked for a public host. A problem answers [invalid_input](/docs/errors#invalid_input) (HTTP 400) and no provider is called. See [Before the call](#before-the-call). 2. **Hold.** The tool's price for your plan is held per item. 3. **Call.** Arettic calls the provider with its own credentials. Each attempt is cut off after 30 seconds, with one retry after an error or a timeout. 4. **Check.** The rule for the tool's task type runs on the answer and gives `pass`, `partial` or `fail`, with a reason for anything but a pass. For `find_email`, Arettic first re-verifies the address with its own verifier and the rule judges that verdict. See [Our own verifier](#our-own-verifier). 5. **Settle.** Straight after the check. A pass captures the price. A partial captures `price × fraction`, rounded up to a whole credit, and releases the rest. A fail releases everything. A provider error, a timeout or an error in the checker itself also releases everything. See [Never charged](#never-charged). 6. **Deliver.** A passed or partial item comes back with its `result`. A failed item comes back with its `reason` only: the paid data is withheld. The full request and response, every status and every reason code are on [Execute](/docs/execute). A charge you believe was wrong can be disputed within the window on [Disputes](/docs/disputes); the reviewer sees the check, its version and the reason it recorded. ## Before the call: the free input check A request that fails the input check never reaches a provider and costs nothing. Two checks run in order: the syntax check on every item, and only if every item passes it, the network check. Every problem is reported at once, the first ten in the message, in the form `input.field: problem` for one input or `inputs[3].field: problem` for a batch, followed by `; and N more` when there are more than ten, and ending with `Nothing was charged.` An input that is not a JSON object is reported as `input: must be an object`. ### The syntax check This is what each task type needs to get past the syntax check, and the exact problem text you get when it does not. A domain is normalised before it is judged: trimmed, lower-cased, and stripped of a leading `http://` or `https://`, a leading `www.` and anything from the first `/`, `?` or `#`. What is left must be 3 to 253 characters of labels made of letters, digits and hyphens (no hyphen at a label's start or end), ending in a top-level label of 2 to 63 letters. So `https://www.acme.com/about` is accepted as `acme.com`. The syntax check per task type | task_type | What is accepted | Problem text when it is not | |---|---|---| | find_email | `first_name` and `last_name`: non-empty strings of up to 100 characters. `domain`: a domain like `acme.com`. | `first_name: required`, `last_name: required`, `domain: must be a domain like acme.com` | | verify_email | `email`: an address of up to 254 characters with a local part, an `@` and a domain whose last label has at least 2 characters. | `email: must be an email address` | | enrich_company | `domain`: a domain, or instead `name`: a non-empty string of up to 200 characters. If neither is usable the problem is reported on `domain`. | `domain: send a domain (or a company name, optionally with country)` | | enrich_person | `first_name` and `last_name`: non-empty strings of up to 100 characters. `company_domain`: a domain. | `first_name: required`, `last_name: required`, `company_domain: must be a domain like acme.com` | | web_search | `query`: a non-empty string of up to 500 characters. `n`: absent, or a whole number from 1 to 25. | `query: required, up to 500 characters`, `n: must be a whole number from 1 to 25` | | extract_url | `url`: a string that parses as a full URL, with the scheme `http` or `https`. | `url: required`, `url: must be a full URL`, `url: must be http or https` | ### The network check With a live key, once every item passes the syntax check, the domains in the request are looked up in DNS and the URLs are checked for a public host. A test key skips this step, because mock tools use made-up domains. - **Domains must exist.** For `find_email` and `enrich_company` the `domain`, for `enrich_person` the `company_domain`, and for `verify_email` the part of `email` after the `@` are each looked up for MX, A and AAAA records. Only a definite answer that the domain does not exist rejects the item, with the problem `the domain acme.example doesn't exist`. If Arettic's own DNS lookup fails or takes more than 2 seconds, the item goes through: an outage on Arettic's side never blocks your request. Answers are cached for an hour when the domain exists and for 10 minutes when it does not. - **URLs must be public.** For `extract_url`, the host of `url` may not be `localhost`, end in `.localhost`, `.local` or `.internal`, or be a loopback, private, link-local or unspecified IP address. The problem is `url: must be a public web address`. **Response (example): a batch with two bad inputs, HTTP 400** ```json { "error": { "code": "invalid_input", "message": "inputs[1].domain: must be a domain like acme.com; inputs[2].first_name: required. Nothing was charged." } } ``` ## Each rule, field by field For every task type: the input fields the tool takes, the output fields a passed `result` has, what the rule does to the result in order, and the reason it records when it stops. The first reason that applies wins. `no_result` always comes first: it means the provider had no record or answered with nothing usable. The input and output tables are generated from the published constants. ### find_email **Passes if:** An email is returned and its verification status is valid (not catch-all or unknown). find_email input | Input field | Meaning | |---|---| | first_name | string | | last_name | string | | domain | company domain, e.g. acme.com | find_email output | Output field | Meaning | |---|---| | email | string | | verification_status | valid | invalid | catch_all | unknown | | confidence | 0–1, optional | 1. No result object, or no non-empty `email` in it: `no_result`. 2. `verification_status` is `catch_all`: `catch_all`. The domain accepts any address, so the one found cannot be confirmed. This is a fail and is refunded. 3. `verification_status` is anything but `valid`: `status:`, for example `status:unknown` or `status:invalid`. A missing status counts as `status:unknown`. 4. Otherwise: pass. With a live key, `verification_status` is Arettic's own verifier's verdict, not the finder's. The finder's own claim is replaced before the rule runs. See [Our own verifier](#our-own-verifier). Provider status names are normalised to the four published values, so `accept_all` from a provider is `catch_all` here. `confidence` is the finder's own score, from 0 to 1, when it gives one; the rule does not use it. ### verify_email **Passes if:** The verifier returns a definitive status (valid or invalid). verify_email input | Input field | Meaning | |---|---| | email | string | verify_email output | Output field | Meaning | |---|---| | email | string | | status | valid | invalid | catch_all | unknown | | sub_status | optional | 1. No result object: `no_result`. 2. `status` is `valid` or `invalid`: pass. Both are definitive answers, and an invalid address is a true, useful result. 3. Any other `status`: `status:`, for example `status:unknown` or `status:catch_all`. A missing status counts as `status:unknown`. ### enrich_company **Passes if:** The record matches the requested domain (or name + country) and has name, domain, employee range and industry. enrich_company input | Input field | Meaning | |---|---| | domain | company domain | | name | company name (if no domain) | | country | optional, with name | enrich_company output | Output field | Meaning | |---|---| | name | string | | domain | string | | employee_range | e.g. 51-200 | | industry | string | | country | optional | 1. No result object, or an empty one: `no_result`. 2. You sent a `domain` and the record's `domain` is not the same after normalisation (lower-case, no scheme, no `www.`, no path): `domain_mismatch`. 3. You sent no `domain` but a `name`, and the record's `name` is less than 0.9 similar to it (see [Name similarity](#name-similarity)): `name_mismatch`. 4. Any of `name`, `domain`, `employee_range` or `industry` is missing or empty, checked in that order: `field_missing:`, for example `field_missing:industry`. 5. Otherwise: pass. `country` is optional and is not checked. When you sent a domain, the name is not compared. ### enrich_person **Passes if:** Name (fuzzy ≥ 0.9) and company domain (exact) match the input; a title and one contact field are present. enrich_person input | Input field | Meaning | |---|---| | first_name | string | | last_name | string | | company_domain | company domain | enrich_person output | Output field | Meaning | |---|---| | name | string | | company_domain | string | | title | string | | email | optional | | phone | optional | | linkedin_url | optional | 1. No result object, or an empty one: `no_result`. 2. The record's `name` is less than 0.9 similar to `first_name` + space + `last_name` from your input (see [Name similarity](#name-similarity)): `name_mismatch`. 3. The record's `company_domain` is not your `company_domain` after normalisation: `company_mismatch`. 4. `title` is missing or empty: `field_missing:title`. 5. None of `email`, `phone` or `linkedin_url` is present: `field_missing:contact`. A person you cannot contact is not the result you paid for. 6. Otherwise: pass. One contact field is enough; the rule does not verify the email or phone it contains. ### web_search **Passes if:** At least N results (5 by default) with valid, non-duplicate URLs. web_search input | Input field | Meaning | |---|---| | query | string, up to 500 characters | | n | 1–25, default 5 | web_search output | Output field | Meaning | |---|---| | results | [{ url, title, snippet? }] | 1. `n` is your input's `n`, or 5 when you did not send one. 2. Every item in `results` with a `url` that parses as an `http` or `https` URL is counted once. Two items with the same URL count as one; an item with no URL or an unparseable one is not counted. 3. No usable URL at all: `no_result`, a fail. 4. At least `n` usable URLs: pass. 5. Some but fewer than `n`: partial, with the reason `results:/` and the fraction `found ÷ n`. See [Partial results](#partial-results). `title` and `snippet` are not checked. A result with a valid URL and no title still counts. ### extract_url **Passes if:** HTTP 200 with at least 200 characters of main content, not a block or captcha page. extract_url input | Input field | Meaning | |---|---| | url | http(s) URL | extract_url output | Output field | Meaning | |---|---| | url | string | | status_code | number | | title | optional | | content | main content as text/markdown | 1. No result object: `no_result`. 2. `status_code` is not 200: `http:`, for example `http:403` or `http:404`; `http:none` when the provider gave no status at all. 3. The first 2,000 characters of `content` contain `captcha`, `access denied`, `are you a robot`, `enable javascript`, `cloudflare` or `request blocked` (case does not matter) and the whole content is under 5,000 characters: `blocked_page`. A long article that merely mentions one of those words is not a block page. 4. `content` has fewer than 200 characters: `content_too_short`. 5. Otherwise: pass. `title` is optional and is not checked. ### Name similarity `enrich_company` (when you sent a name and no domain) and `enrich_person` compare names with a similarity score from 0 to 1 and require at least 0.9. Both names are lower-cased and everything that is not a letter `a` to `z` is removed, so spaces, dots, hyphens and case never matter: `Emily Carter` and `emily-carter` are identical. The score is 1 minus the Levenshtein edit distance divided by the length of the longer string. `Emily Carter` against `emily.carter` is 1.0 and passes. `Jason Miller` (11 letters) against `Jason Millerr` (12 letters) is 1 − 1 ÷ 12 ≈ 0.92 and passes. `Amy Ross` (7 letters) against `Amy Rose` (7 letters) is 1 − 1 ÷ 7 ≈ 0.86 and fails with `name_mismatch`. Short names are therefore strict: a one-letter difference in a seven-letter name is a mismatch. ## Partial results Only `web_search` can be partial. You ask for `n` results (the default is 5). If the provider returns at least `n` valid, distinct URLs, the item passes and the full price is charged. If it returns some but fewer, you get all of them and pay `price × found ÷ n`, rounded up to the next whole credit; the rest of the hold is released. The answer's `status` is `partial` and its `reason` is `results:/`. No usable URL at all is a fail with `no_result`, not a partial. Pro rata charges on a 10-credit web search with n = 5 | Found | Fraction | Charged | Refunded | status | |---|---|---|---|---| | 5 or more | 1 | 10 | 0 | `passed` | | 4 | 0.8 | 8 | 2 | `partial` | | 2 | 0.4 | 4 | 6 | `partial` | | 1 | 0.2 | 2 | 8 | `partial` | | 0 | 0 | 0 | 10 | `failed` (`no_result`) | Rounding is up, so 2 of 5 on a 7-credit tool is `ceil(7 × 0.4)` = 3 credits, not 2.8. With a test key the same fraction is reported as `would_have_charged`. **Response (example): 2 of 5 results on a 10-credit tool** ```json { "status": "partial", "execution_id": "5e8c1a7b-2d3f-4a6e-8b9c-1f2e3d4c5b6a", "receipt_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "tool_id": "exa-web-search", "task_type": "web_search", "check_version": "v1", "charged": { "credits": "4", "usd": "0.004" }, "refunded": { "credits": "6", "usd": "0.006" }, "result": { "results": [ { "url": "https://example.com/saas-directory", "title": "US SaaS companies", "snippet": "A directory of…" }, { "url": "https://acme.com/blog/saas-in-the-us", "title": "SaaS in the US", "snippet": "The market…" } ] }, "reason": "results:2/5" } ``` ## Our own verifier for found emails A finder that reports its own address as valid is grading its own work. So for `find_email`, with a live key, Arettic does not take the finder's word for it. Once the finder returns an address, Arettic sends that address to its own verifier, a `verify_email` tool from the catalog that is not the tool you bought from, and writes the verifier's `status` into the result's `verification_status`. The `find_email` rule then judges that verdict. If the finder says `valid` and the verifier says `catch_all`, the item fails with `catch_all` and is refunded. - The verifier's call is paid by Arettic. It is built into the tool's price and recorded on the execution as checker overhead; it is never added to your charge. - The verifier gets 35 seconds. If it errors or times out, the status is `unknown`, the rule fails the item with `status:unknown`, and you are refunded. Arettic's verifier failing never costs you the price. - If no verifier is configured on Arettic's side, the finder's own `verification_status` is judged instead. Either way the published rule is the same: only `valid` passes. - With a test key there is no second call. The mock finder's own `verification_status` is judged, so a domain ending in `.catchall.test` fails with `catch_all`. See [Test mode](/docs/test-mode#make-a-mock-fail). This is why `find_email` is the strictest rule: a passed address has been found by one provider and confirmed deliverable by another. ## Never charged: provider errors, timeouts and Arettic's own bugs The pass rule only runs on an answer. When there is no answer, the item is released without running it, and the reason says why. None of these is ever charged, under any pricing mode. Reasons that are released before or instead of the check | reason | When | |---|---| | provider_error | The provider failed on both attempts: a 5xx, a 429, another 4xx such as a rejected key, a connection failure, or no adapter configured for the tool. A provider that answers 404 or 422 is not an error: that is an empty answer, and the rule fails it with `no_result`. | | timeout | No answer within 30 seconds, on both attempts. | | check_error | The rule itself threw on this result. Counted as a fail so you never pay for Arettic's bug. The check is recorded on the execution so it can be fixed. | | interrupted | The process died while the item was mid-call. A safety net releases the hold within a few minutes. | | cancelled | The item's job was cancelled, or the hold was force-settled, before it ran. | A provider error or a timeout also counts against the tool's reliability score, and a run of them pauses the tool, so a failing provider is routed around rather than paid. If you sent `fallback: true`, a failed check, a provider error or a timeout is retried once on the next-ranked tool within your `max_price`; see [Execute](/docs/execute#fallback). ## check_version: how rules change Every rule has a version, currently `v1` for every rule. The version that judged a result is the `check_version` on the execute answer and on the receipt, at the top for the request and on every item in `items[]`. A receipt keeps the version that was applied at the time, for ever: if the rule for a task type moves to `v2` later, a receipt checked under `v1` still says `v1`, and a dispute on it is decided against `v1`. - A rule changes only by a new version. The text and the code of `v1` do not change once published. - A new version is announced on the [changelog](/changelog) under the Pass rules area, with what changed and from when, before it takes effect. The changelog is also available as Markdown, JSON and RSS. - From that date, new executions of that task type carry the new version. Executions and receipts before it are untouched. - The `pass_rule` on every tool (`task_type@version`) and on `GET /v1/task-types` moves with it, so an agent that reads the version from the API sees the change without reading the changelog. There is no way to ask for an older version on a new request. The current rule is the only rule a new result is checked against. ## Read the rules from the API The rules are open data (CC BY 4.0) and need no key. `GET https://api.arettic.com/v1/task-types` returns one entry per task type with its `pass_rule` name, `passes_if`, `input` and `output`. `GET https://api.arettic.com/v1/formula` returns the score formula and its rules, and under `pass_rules` the same rules with `partial_or_failed` as well. Both carry the `license` and a `generated_at` time, and answers are cached for 60 seconds. [Public data](/docs/public-data) has every open endpoint. **curl** ```bash curl https://api.arettic.com/v1/task-types curl https://api.arettic.com/v1/formula ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic(); // no key needed for public data const { task_types } = await arettic.taskTypes(); for (const t of task_types) console.log(t.pass_rule, t.passes_if); const formula = await arettic.formula(); console.log(formula.pass_rules); // every rule with partial_or_failed ``` **Python** ```python from arettic import Arettic client = Arettic() # no key needed for public data for t in client.task_types()["task_types"]: print(t["pass_rule"], t["passes_if"]) print(client.formula()["pass_rules"]) # every rule with partial_or_failed ``` Over MCP the `list_task_types` tool takes no arguments and returns the same JSON as `GET /v1/task-types` as text content. Client configs are on [MCP server](/docs/mcp). **MCP tool call** ```json { "name": "list_task_types", "arguments": {} } ``` **Response (example): GET /v1/task-types, one entry shown** ```json { "task_types": [ { "task_type": "find_email", "pass_rule": "find_email@v1", "passes_if": "An email is returned and its verification status is valid (not catch-all or unknown)", "input": { "first_name": "string", "last_name": "string", "domain": "company domain, e.g. acme.com" }, "output": { "email": "string", "verification_status": "valid | invalid | catch_all | unknown", "confidence": "0–1, optional" } } ], "license": { "id": "CC-BY-4.0", "url": "https://creativecommons.org/licenses/by/4.0/", "attribution": "Arettic (arettic.com)" }, "generated_at": "2026-09-29T10:00:00.000Z" } ``` ## See also - [Execute](/docs/execute): the request, every status, and the full list of reason codes. - [Receipts](/docs/receipts): where `check_version`, `outcome` and `reason` live on a receipt. - [Disputes](/docs/disputes): what to do when a passed result was wrong. - [Test mode](/docs/test-mode): the inputs that make each mock tool pass, fail or go partial. - [Error codes](/docs/errors): `invalid_input` and every other refusal. - [Schemas](/schemas): the JSON Schemas for execute requests and responses. Updated 2026-09-29. This page as HTML: https://arettic.com/docs/pass-rules · Markdown: https://arettic.com/docs/pass-rules.md · JSON: https://arettic.com/docs/pass-rules.json --- # How scores work The score formula, its six inputs, where each comes from, when scores change and how to cite one. Every tool gets a score from 0 to 100 for each task type and region it serves. It says how likely the tool is to give your agent a correct, usable result. Scores come from our own benchmarks against test sets with known answers, plus what happens on live purchases. Nobody can pay for a better score: whether a tool can be bought through Arettic, or its provider pays us for analytics, never changes its rank. ## The formula (v1) **Score** ```text Score = 100 × (0.40A + 0.25S + 0.20P + 0.10R + 0.05L) − min(20, 500D), clamped to 0–100 ``` The six inputs, each a rate from 0 to 1 | Input | Weight | What it is | Where it comes from | |---|---|---|---| | A, accuracy | 0.40 | Share of benchmark cases where a correct result was delivered. | The tool's latest benchmark run: each case's result is compared with the test set's known answer. | | S, pass rate | 0.25 | Share of calls whose result passed the pass rule. | Benchmark cases plus live calls in the last 28 days, weighted by volume. | | P, audit precision | 0.20 | Share of audited passes a reviewer confirmed correct. | The weekly audit. Until a tool has 50 audited results, P uses A. | | R, reliability | 0.10 | 1 − the share of provider errors and timeouts. | Benchmark and live calls in the last 28 days. | | L, speed | 0.05 | The task type's median latency ÷ this tool's latency, capped at 1. | Benchmark p50 latency, compared with the other tools for the same task type and region. | | D, disputes | penalty | Upheld disputes ÷ passed live results. | Live purchases in the last 28 days. Each 0.1% of upheld disputes costs half a point, up to 20 points. | ## Rules - **Small samples are cautious.** A rate from fewer than 200 data points uses the 95% Wilson lower bound, so a tool with 9 passes out of 10 doesn't outrank one with 900 out of 1,000. - **Scores move slowly.** A score moves at most ±10 points a week, unless the tool is paused (then it can drop freely). - **Weekly.** Scores are recomputed every Monday at 00:00 UTC. Benchmarks re-run on the 1st of every month. - **Per task type and region.** A tool that verifies emails well in the US may not in the UK; each gets its own score, and each score says how many data points (`sample_size`) it rests on. - **Ranking.** `recommend` ranks by score; within 2 points, the cheaper tool goes first. Every score input is in the answer. - **A floor for sale.** A tool can only be bought through Arettic after a recent benchmark with accuracy of at least 60%. ## Get scores - `GET https://api.arettic.com/v1/scores`: the current score of every tool (filter with `task_type` and `region`). - `GET https://api.arettic.com/v1/tools/{id}`: one tool with its score per region, every input, the weekly history and its latest benchmark. - `GET https://api.arettic.com/v1/formula`: the formula, weights, inputs, rules and pass rules as JSON. - The [scores page](/scores) and each tool's page show the same, and so do their `.md` and `.json` copies. No key needed; see [public data](/docs/public-data). **curl** ```bash curl "https://api.arettic.com/v1/scores?task_type=verify_email®ion=US" ``` ## Citing a score Scores are published under CC-BY-4.0: use them anywhere, with the attribution "Arettic (arettic.com)". Cite the tool, the task type and region, the score, the week it was computed, the formula version and the sample size, for example: "ZeroBounce, verify_email (US): 87.4, week of 2026-09-28, formula v1, n = 1,240. Source: Arettic (arettic.com)." ## Check it yourself The formula has an independent reference implementation in Python (`harness/arettic_harness/formula.py`, published with the benchmark harness), tested against the same cases as ours. With the published inputs of any score, you can recompute it. ## Scores in test mode With a test key, `recommend` returns only the mock tools, one per task type. Mock tools never appear in public scores (unless you ask `/v1/tools` for `mode=test`). See [test mode](/docs/test-mode). ## Private benchmarks On the Pro plan you can benchmark tools on your own test set: upload cases with known answers (`POST /v1/orgs/{orgId}/test-sets`), pick tools, and run (`POST /v1/orgs/{orgId}/benchmarks`). The results use the same pass rules and accuracy checks as ours and are private to your org: they never change public scores. 500 calls a month are included; beyond that, calls are charged at the provider's list price × your plan's multiplier, pass or fail. Updated 2026-09-30. This page as HTML: https://arettic.com/docs/scores · Markdown: https://arettic.com/docs/scores.md · JSON: https://arettic.com/docs/scores.json --- # Org API: everything the dashboard does Org keys drive every dashboard action through the API: agents, budgets, approvals, receipts, billing, webhooks and team. Anything a person can do in the dashboard, an agent can do through the API. Agent keys (`sk_…`) buy results; **org keys** (`ok_…`) run the org: make agents and set their budgets, decide approvals, read receipts, top up, manage webhooks and the team. ## Org keys - An owner makes them on the Team page while signed in (or `POST /v1/orgs/{orgId}/keys` with a session). The key is shown once. - Scope `read` can only read; anything else gets `403 forbidden` ("This org key is read-only"). Scope `write` can do anything its maker can do on the dashboard. - A key acts as the owner who made it, works only on `/v1/orgs/{orgId}/…` for its own org, and stops working if that owner leaves the org. - Keys can't make or revoke keys, and can't ask for data deletion: both need a person signed in. - Up to 20 active keys per org. 300 requests a minute per key; see [rate limits](/docs/rate-limits). Send it as `Authorization: Bearer ok_…`. The org's id is in the dashboard's URLs (`?org=…`) and in `GET /v1/me`. ## Worked examples ### Make an agent and read its key **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/agents \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Lead research", "mode": "live", "monthly_budget_credits": 100000 }' # → { "agent": { "id": "…", … }, "key": "sk_live_…", "key_note": "…" } (the key is shown once) ``` **TypeScript** ```ts import { AretticOrg } from "@arettic/sdk"; const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, orgId: "your-org-id" }); const { agent, key } = await org.agents.create({ name: "Lead research", mode: "live" }); await org.agents.update(agent.id, { monthly_budget_credits: 100_000, approval_threshold_credits: 20_000 }); ``` **Python** ```python import os from arettic import AretticOrg org = AretticOrg(os.environ["ARETTIC_ORG_KEY"], os.environ["ARETTIC_ORG_ID"]) made = org.agents.create(name="Lead research", mode="live") org.agents.update(made["agent"]["id"], monthly_budget_credits=100_000, approval_threshold_credits=20_000) ``` ### A month of receipts, and the CSV **TypeScript** ```ts const page = await org.receipts.list({ from: "2026-09-01", to: "2026-09-30", limit: 200 }); const csv = await org.receipts.exportCsv({ from: "2026-09-01", to: "2026-09-30" }); ``` **Python** ```python page = org.receipts.list(from_="2026-09-01", to="2026-09-30", limit=200) csv = org.receipts.export_csv(from_="2026-09-01", to="2026-09-30") ``` ### Decide an approval **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/decision \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "decision": "approve", "note": "Q4 list" }' ``` **TypeScript** ```ts const pending = await org.approvals.list({ status: "pending" }); for (const a of pending) await org.approvals.decide(a.approval_id, { decision: "approve" }); ``` **Python** ```python for a in org.approvals.list(status="pending")["approvals"]: org.approvals.decide(a["approval_id"], "approve") ``` ### Add a webhook **TypeScript** ```ts const hook = await org.webhooks.create({ url: "https://example.com/arettic-hook", events: ["job.completed"] }); // hook.webhook.secret is shown once: store it to verify signatures ``` **Python** ```python hook = org.webhooks.create("https://example.com/arettic-hook", events=["job.completed"]) secret = hook["webhook"]["secret"] # shown once ``` Verifying deliveries is on [webhooks](/docs/webhooks). The dashboard's overview numbers (balance, spend by day, agent and tool, items not charged, budgets, pending approvals and disputes) are `GET /v1/orgs/{orgId}/dashboard`. ## Errors you'll meet - `403 forbidden`: a read key tried to change something, a key was used on another org, or a member tried an owner-only action. - `402 plan_limit`: the plan's agents, seats or webhook endpoints are used up. - `404 not_found`: the thing isn't in this org. - `429 rate_limited`: over 300 requests a minute. ## Every dashboard action and its endpoint Generated from the list our tests check: every server action in the dashboard is here, every endpoint is in [/openapi.json](/openapi.json), and an org key is tested against every row marked yes. Dashboard ↔ API | Action | Dashboard page | Endpoint | Org key | |---|---|---|---| | Email me a sign-in link | `/app` | `POST /auth/email/start` | no: a person signed in | | Sign out | `/app` | `POST /auth/logout` | no: a person signed in | | Create an org | `/app` | `POST /v1/orgs` | no: a person signed in | | Accept an invite | `/invite` | `POST /v1/invites/accept` | no: a person signed in | | Balance, spend and refund rate | `/app` | `GET /v1/orgs/{orgId}/dashboard` | yes | | List agents | `/app/agents` | `GET /v1/orgs/{orgId}/agents` | yes | | Add an agent (key shown once) | `/app/agents` | `POST /v1/orgs/{orgId}/agents` | yes | | Change name, budget, approval threshold, IP allowlist or status | `/app/agents` | `PATCH /v1/orgs/{orgId}/agents/{agentId}` | yes | | Rotate an agent key | `/app/agents` | `POST /v1/orgs/{orgId}/agents/{agentId}/rotate-key` | yes | | Revoke an agent key | `/app/agents` | `POST /v1/orgs/{orgId}/agents/{agentId}/revoke-key` | yes | | Approval requests | `/app/approvals` | `GET /v1/orgs/{orgId}/approvals` | yes | | Approve or reject a purchase | `/app/approvals` | `POST /v1/orgs/{orgId}/approvals/{id}/decision` | yes | | Receipt explorer | `/app/receipts` | `GET /v1/orgs/{orgId}/receipts` | yes | | One receipt | `/app/receipts/[id]` | `GET /v1/orgs/{orgId}/receipts/{id}` | yes | | Export receipts as CSV | `/app/receipts` | `GET /v1/orgs/{orgId}/exports/receipts.csv` | yes | | Dispute a charged item | `/app/receipts/[id]` | `POST /v1/orgs/{orgId}/disputes` | yes | | Disputes and their decisions | `/app/disputes` | `GET /v1/orgs/{orgId}/disputes` | yes | | Invoices | `/app/invoices` | `GET /v1/orgs/{orgId}/invoices` | yes | | One invoice (JSON or printable HTML) | `/app/invoices` | `GET /v1/orgs/{orgId}/invoices/{id}` | yes | | Balance and credit lots | `/app/billing` | `GET /v1/orgs/{orgId}/balance` | yes | | Top-up history | `/app/billing` | `GET /v1/orgs/{orgId}/topups` | yes | | Buy credits (returns a checkout link) | `/app/billing` | `POST /v1/orgs/{orgId}/topups` | yes | | Auto-reload settings | `/app/billing` | `GET /v1/orgs/{orgId}/auto-reload` | yes | | Turn auto-reload on or off | `/app/billing` | `PUT /v1/orgs/{orgId}/auto-reload` | yes | | Low-balance alert level | `/app/billing` | `PUT /v1/orgs/{orgId}/notifications` | yes | | Plan, limits and subscriptions | `/app/billing` | `GET /v1/orgs/{orgId}/plan` | yes | | Start the Pro plan (returns a checkout link) | `/app/billing` | `POST /v1/orgs/{orgId}/subscriptions` | yes | | Cancel a plan at period end | `/app/billing` | `DELETE /v1/orgs/{orgId}/subscriptions/{plan}` | yes | | Billing name, address, country and tax ID | `/app/billing` | `PATCH /v1/orgs/{orgId}/billing` | yes | | Webhook endpoints and event types | `/app/webhooks` | `GET /v1/orgs/{orgId}/webhooks` | yes | | Recent deliveries | `/app/webhooks` | `GET /v1/orgs/{orgId}/webhook-deliveries` | yes | | Recent events | `/app/webhooks` | `GET /v1/orgs/{orgId}/events` | yes | | Add an endpoint (secret shown once) | `/app/webhooks` | `POST /v1/orgs/{orgId}/webhooks` | yes | | Send a test event | `/app/webhooks` | `POST /v1/orgs/{orgId}/webhooks/{id}/test` | yes | | Delete an endpoint | `/app/webhooks` | `DELETE /v1/orgs/{orgId}/webhooks/{id}` | yes | | Members and pending invites | `/app/team` | `GET /v1/orgs/{orgId}/members` | yes | | Invite by email | `/app/team` | `POST /v1/orgs/{orgId}/invites` | yes | | Withdraw an invite | `/app/team` | `DELETE /v1/orgs/{orgId}/invites/{inviteId}` | yes | | Make a member an owner, or back | `/app/team` | `PATCH /v1/orgs/{orgId}/members/{userId}` | yes | | Remove a member, or leave | `/app/team` | `DELETE /v1/orgs/{orgId}/members/{userId}` | yes | | Org API keys | `/app/team` | `GET /v1/orgs/{orgId}/keys` | no: a person signed in | | Make an org API key (shown once) | `/app/team` | `POST /v1/orgs/{orgId}/keys` | no: a person signed in | | Revoke an org API key | `/app/team` | `DELETE /v1/orgs/{orgId}/keys/{id}` | no: a person signed in | | Data-deletion requests | `/app/team` | `GET /v1/orgs/{orgId}/deletion-requests` | yes | | Ask us to delete stored inputs and results (within 24 hours) | `/app/team` | `POST /v1/orgs/{orgId}/deletion-requests` | no: a person signed in | | Private test sets and included calls | `/app/benchmarks` | `GET /v1/orgs/{orgId}/test-sets` | yes | | Private benchmark runs | `/app/benchmarks` | `GET /v1/orgs/{orgId}/benchmarks` | yes | | One run: per-case results, segments, failure reasons | `/app/benchmarks/[id]` | `GET /v1/orgs/{orgId}/benchmarks/{id}` | yes | | Make a private test set (JSON Lines) | `/app/benchmarks` | `POST /v1/orgs/{orgId}/test-sets` | yes | | Add cases to a test set | `/app/benchmarks` | `POST /v1/orgs/{orgId}/test-sets/{id}/cases` | yes | | Delete a test set and its results | `/app/benchmarks` | `DELETE /v1/orgs/{orgId}/test-sets/{id}` | yes | | Run tools against a test set | `/app/benchmarks` | `POST /v1/orgs/{orgId}/benchmarks` | yes | | Your tools' scores, inputs and rank (read-only) | `/app/provider` | `GET /v1/orgs/{orgId}/provider` | yes | | Claim a provider | `/app/provider` | `POST /v1/orgs/{orgId}/provider/claims` | yes | | Ask for a re-test (Insights, Pro) | `/app/provider` | `POST /v1/orgs/{orgId}/provider/retests` | yes | Updated 2026-09-30. This page as HTML: https://arettic.com/docs/org-api · Markdown: https://arettic.com/docs/org-api.md · JSON: https://arettic.com/docs/org-api.json --- # Webhooks and notifications One event list, delivered as signed webhooks on every plan and emailed to owners where a person should know. Arettic has one list of events. Each event is sent to your webhook endpoints, signed, and retried for 24 hours until your server answers 2xx. Some are also emailed to the org's owners. Webhooks are on every plan, and so is the list of recent events. ## Add an endpoint `POST https://api.arettic.com/v1/orgs/{orgId}/webhooks` as an owner (or with a write org key), or on the Webhooks page of the dashboard. Send `url`, and optionally `events`: a list of event types to receive. No `events`, or an empty list, means every event. - The answer carries the endpoint's signing secret (`whsec_…`) **once**. Store it now; it isn't shown again. It is kept encrypted with your org's key. - In production the URL must be `https` and a public address (not a private or local network). `http://localhost` works while you develop against a local Arettic. - Up to 10 endpoints per org; the 11th gets `402 plan_limit`. - `DELETE /v1/orgs/{orgId}/webhooks/{id}` removes one; `GET /v1/orgs/{orgId}/webhooks` lists them with the event types you can pick. **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/webhooks \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/arettic-hook", "events": ["job.completed", "dispute.decided"] }' ``` **Response** ```json { "webhook": { "id": "0b8e2c4d-6f1a-4e3b-9c5d-7e8f9a0b1c2d", "secret": "whsec_4f1c…", "url": "https://example.com/arettic-hook", "events": ["job.completed", "dispute.decided"] }, "note": "Store the secret now: it isn't shown again." } ``` ## Events Every event type and its data | Type | When | `data` fields | Emailed to owners | |---|---|---|---| | `balance.low` | The org's balance fell under its low-balance alert (default $5; set it with `PUT /v1/orgs/{orgId}/notifications` and `low_balance_usd`). Once per top-up. | `balance_credits`, `balance_usd` | yes | | `budget.80pct` | A live agent has spent 80% of its monthly budget. Once per agent per month. | `agent_id`, `agent_name`, `spent_credits`, `budget_credits` | yes | | `approval.requested` | A purchase is waiting for an owner's approval. | `approval_id`, `agent_id`, `tool_id`, `item_count`, `amount_credits`, `amount_usd`, `reason` (`over_threshold` or `over_budget`), `expires_at` | no (owners get their own email with a one-click link) | | `approval.decided` | An owner approved or rejected it. | `approval_id`, `agent_id`, `status` (`approved` or `rejected`), `note` | no | | `job.completed` | A job (more than 25 items) finished. | `job_id`, `status`, `receipt_id`, `item_count`, `passed`, `expired` | no | | `tool.paused` | A tool your org used in the last 30 days was paused. | `tool_id`, `tool_name`, `reason` | yes | | `dispute.decided` | One of your disputes was decided. | `dispute_id`, `receipt_id`, `item_index`, `decision` (`upheld` or `rejected`), `refunded_credits`, `note` | yes | | `price.changed` | The price per success of a tool your org used in the last 30 days changed. | `tool_id`, `tool_name`, `old_credits`, `new_credits`, `valid_from` | yes | | `trial.expiring` | Trial credits expire within 7 days. Once per grant. | `credits`, `expires_on` | yes | | `webhook.test` | You asked for a test delivery (below). | `message` | no | `GET https://api.arettic.com/v1/orgs/{orgId}/events` lists recent events, whether or not you have endpoints: a way to catch up after downtime. ## The delivery Each delivery is a `POST` with a JSON body and these headers: Delivery headers | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `Arettic-Webhooks/1.0` | | `Arettic-Event-Id` | The event's id: the same on every retry, so you can drop duplicates. | | `Arettic-Event-Type` | The event type, e.g. `job.completed`. | | `Arettic-Signature` | `t=,v1=` (below). | **job.completed (example values)** ```json { "id": "6a1d3f5b-2c4e-4d6f-8a1b-3c5d7e9f1a2b", "type": "job.completed", "created_at": "2026-09-30T11:04:52.000Z", "data": { "job_id": "d3b07384-d9a0-4c9b-8f1e-2a5c7e9b1d3f", "status": "done", "receipt_id": "9e107d9d-372b-4b6a-8a1f-3c5d7e9f1a2b", "item_count": 200, "passed": 181, "expired": 0 } } ``` **approval.requested (example values)** ```json { "id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b", "type": "approval.requested", "created_at": "2026-09-30T11:10:00.000Z", "data": { "approval_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "agent_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "tool_id": "peopledatalabs-enrich-company", "item_count": 400, "amount_credits": "24000", "amount_usd": "24.000", "reason": "over_threshold", "expires_at": "2026-10-01T11:10:00.000Z" } } ``` ## Verify the signature `Arettic-Signature` is `t=,v1=`, where the signature is the hex HMAC-SHA256, keyed with your endpoint's secret, of the string `.`. Verify it against the **raw** body, before parsing the JSON, and reject a `t` more than 5 minutes from your clock (it stops replays). Compare in constant time. **Node / TypeScript** ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyArettic(secret: string, rawBody: string, header: string): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string])); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b); } ``` **Python** ```python import hashlib import hmac import time def verify_arettic(secret: str, raw_body: bytes, header: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) try: t = int(parts["t"]) except (KeyError, ValueError): return False if "v1" not in parts or abs(time.time() - t) > 300: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` ## Retries Any 2xx answer within 10 seconds counts as delivered. Anything else (another status, a timeout, a redirect, a refused connection) is retried after 1, 5, 15, 30, 60, 120, 240, 480 and 480 minutes: ten tries over about 24 hours. After that, or once the next try would fall past 24 hours from the event, the delivery is marked failed. Redirects are never followed. Deliveries can arrive out of order and, rarely, more than once. Use `Arettic-Event-Id` to drop duplicates, and the event's `created_at` or the object it points to (fetch the job, the receipt, the dispute) for the current state. ## Test and debug - `POST /v1/orgs/{orgId}/webhooks/{id}/test` sends a `webhook.test` event to that one endpoint straight away and answers with the delivery's `status`, `last_status_code` and `last_error`. - `GET /v1/orgs/{orgId}/webhook-deliveries` lists recent deliveries: status, attempts, the last status code or error, and when the next try is. - The Webhooks page of the dashboard shows both, and the SDKs have `org.webhooks.create`, `.list`, `.test` and `.delete`. ## Schema The event envelope and every event's `data` are published as JSON Schema at [/schemas/webhook-event.json](/schemas/webhook-event.json). Updated 2026-09-30. This page as HTML: https://arettic.com/docs/webhooks · Markdown: https://arettic.com/docs/webhooks.md · JSON: https://arettic.com/docs/webhooks.json --- # Credits, top-ups and plans The credit unit, balances, top-ups in US dollars, auto-reload, expiry, trial credits and plans. Everything on Arettic is paid in credits. **1 credit = $0.001**, and prices are whole credits. Money in the API is always `{ "credits": "7", "usd": "0.007" }`. An org holds the credits; its agents spend them, within their [budgets](/docs/budgets-and-approvals). A purchase is charged only when its result passes the [pass rule](/docs/pass-rules). ## Balance - `GET https://api.arettic.com/v1/balance` with an agent key: `org_balance` (`paid`, `trial`, `total`) and the agent's `agent_spent_month`, `agent_budget` and `agent_budget_left`. Over MCP: `get_balance`. - `GET https://api.arettic.com/v1/orgs/{orgId}/balance` for a member or an org key: the org's `paid`, `trial` and `total`. - Trial credits are always spent before paid ones. A purchase that needs more than the total is declined with `402 insufficient_credits`. ## Top-ups `POST https://api.arettic.com/v1/orgs/{orgId}/topups` with `amount_usd` (whole dollars) and optionally `save_card: true`, or the Billing page of the dashboard. The answer has a `checkout_url` to pay at; the credits land when the payment clears. Top-up rules | Rule | Value | |---|---| | First top-up | at least $20 | | Later top-ups | at least $50 | | Largest single top-up | $10,000 | | New orgs, first 14 days | at most $200 in total | | Currency | US dollars | | Invoices | Every paid top-up gets one: `GET /v1/orgs/{orgId}/invoices`. Any sales tax is shown separately. | **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/topups \ -H "Authorization: Bearer $ARETTIC_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount_usd": 50, "save_card": true }' ``` ## Auto-reload Save a card with a top-up (`save_card: true`), then `PUT https://api.arettic.com/v1/orgs/{orgId}/auto-reload` with `enabled`, `threshold_usd` (at least $1) and `amount_usd` (at least $50). Every 5 minutes, an org whose paid credits are under the threshold is charged the amount (plus any sales tax) and gets an invoice, at most once every 10 minutes. A declined card turns auto-reload off and emails the owners; it never retries on its own. ## Expiry - **Paid credits** expire 12 months after the top-up that bought them. Spending is oldest-first, so only credits you haven't used in a year expire. Owners get an email 30 days before. - **Trial credits** expire 30 days after they're granted; a `trial.expiring` event comes 7 days before. ## Trial credits - $1 when you make your org. No phone or card needed. One per company domain (or per address, for free email). - $10 more when you have a demo call with us. - Until an org's first top-up, it can spend at most 50 trial credits an hour (`429 trial_limit`). ## Low-balance alert `PUT https://api.arettic.com/v1/orgs/{orgId}/notifications` with `low_balance_usd` (default $5). When the balance falls under it, the owners get an email and webhooks get `balance.low`, once per top-up. See [webhooks](/docs/webhooks). ## What a result costs Each tool has a price per success: price per success = C ÷ S_price × k, rounded up to whole credits, where C is our cost per call, S_price the tool's recent pass rate and k your plan's multiplier (never below 1.25). Prices are recomputed daily; a move over 20% is reviewed by a person first, and orgs that used the tool get `price.changed`. Every tool's price is on [/pricing](/pricing) and at `GET https://api.arettic.com/v1/pricing`. ## Plans Plans | Plan | Price | k | Agents | Seats | Score lookups a day | Free-text lookups a day | |---|---|---|---|---|---|---| | Pay as you go | $0 + credits | 1.5× | 2 | 1 | 1,000 | 100 | | Pro | $99 a month or $990 a year | 1.3× | 10 | 5 | 10,000 | 500 | | Max | From $1,000 a month | 1.25× | no limit | no limit | no limit | no limit | Start Pro with `POST https://api.arettic.com/v1/orgs/{orgId}/subscriptions` and `{ "plan": "team", "interval": "month" }` (or `"year"`); the answer has a checkout link. `GET /v1/orgs/{orgId}/plan` shows the plan and what it includes; `DELETE /v1/orgs/{orgId}/subscriptions/team` cancels at the end of the period. Until 30 days after public launch, the first 50 teams can take the founding Team price: $490 instead of $990 for the first year (`"founding": true`, yearly only). Enterprise is by contract. ## Frozen credits Rarely, while we look into something on an account (a chargeback, a card flagged by the payment provider), we freeze its credits. Purchases are then declined with `403 credits_frozen`; sign-in, reads, receipts and exports keep working. Email support and we'll tell you what's needed. ## Credits stay on Arettic Credits are for buying results on Arettic. They can't be cashed out, sold, or moved to another org. Updated 2026-09-30. This page as HTML: https://arettic.com/docs/credits · Markdown: https://arettic.com/docs/credits.md · JSON: https://arettic.com/docs/credits.json --- # Data handling and retention What we store, for how long, encrypted with what, and how to have it deleted. Your agents send us inputs (names, domains, emails, URLs) and buy results. This page says what we keep, how, for how long, who can see it, and how to have it deleted. The legal version is the [privacy policy](/privacy). ## Stored inputs and results - Each live purchase's input and result are kept for **7 days**, then deleted. We keep them that long so a [dispute](/docs/disputes) can be checked against what was actually sold, and so the weekly audit can sample them. - An item under dispute is kept until the dispute is decided: at most 48 hours past the 7 days. - They are encrypted with AES-256-GCM using a key unique to your org. That key is itself stored only encrypted, under a master key kept outside the database. A stored item can't be read as, or moved to, another org's. - Test-mode purchases store nothing: the mock providers don't see real data and nothing is kept. - The inputs of a job wait encrypted the same way and are deleted when the job finishes. ## What stays after deletion The [receipt](/docs/receipts) of each purchase stays, because it is your record of what you paid: the tool, the provider, the price, the outcome, the check version, refunds, and SHA-256 hashes of the input and the result. A hash can confirm a result you still hold; it can't be turned back into the data. We also keep the outcome, timing and a coarse segment of each call (for example the task type and region) to compute [scores](/docs/scores). ## Ask for deletion An owner can ask us to delete the org's stored inputs and results, its private test sets and its storage key, on the Team page of the dashboard or with `POST https://api.arettic.com/v1/orgs/{orgId}/deletion-requests` (optional `note`). It is done within **24 hours**, and we email the owner who asked when it is. Destroying the org's key makes anything encrypted with it unreadable at once. Receipts, invoices and your balance stay: they hold no inputs or results, and invoices are tax records. - Asking needs an owner signed in; an org key gets `403 forbidden`. One request can be open at a time (`409 conflict`). - `GET https://api.arettic.com/v1/orgs/{orgId}/deletion-requests` (members and org keys) shows each request, `done_by` (the deadline), `completed_at` and how many items were deleted. - If nobody at Arettic gets to it first, it is completed automatically in its last hour, so the 24 hours hold regardless. **curl** ```bash curl https://api.arettic.com/v1/orgs/$ORG_ID/deletion-requests \ -H "Authorization: Bearer $ARETTIC_SESSION" \ -H "Content-Type: application/json" \ -d '{ "note": "End of the pilot" }' ``` ## Who sees your data - **The provider of the tool you run** receives that request's input (with fallback on, the provider of the substitute tool). For `find_email`, the found email may also go to our email verifier. Nobody else receives it. - **No other customer.** Nothing you send or buy is shared with, shown to, or cached for another customer. - **No training.** We never use your inputs or results to train models, and never sell them. - **Arettic staff** open a stored item only to decide a dispute, to review an item flagged by the weekly audit, or to re-check results when a pass rule or the score formula changes. Every look is logged with who and why. ## The weekly audit Every week we sample up to 100 recently passed results per task type from the last 3 days and review them, to check that our pass rules pass the right things. The review is automated (rule-based plausibility checks today; an AI reviewer can be switched on later), and anything it is unsure about goes to a person at Arettic. It reads the stored input and result; like every other look, it is logged. Its findings feed the accuracy input of the scores; it never changes what you were charged. ## Acceptable use: people lookups To stop bulk collection of one company's staff, an org can make at most **500 people lookups** (`find_email`, `enrich_person`, `verify_email`) **per company domain per day** (UTC). The request that would pass it is declined with `429 aup_limit`, nothing is held or charged, and the incident is recorded for review. Counters are kept by a hash of the domain, so we don't keep a list of the companies you look up. Contact support if you have a legitimate research need. ## Other data we keep Retention | Data | Kept for | |---|---| | Stored inputs and results | 7 days (under dispute: until decided, at most 48 hours more); deleted within 24 hours of a deletion request | | Private benchmark test sets | Until you delete them or ask for deletion | | Receipts, invoices, the ledger | Kept: they are your records and ours, and contain no inputs or results | | API request log (route, status, timing, org and agent IDs, error code; never inputs) | 14 days | | Rate-limit counters | 1 day | ## Where it lives Arettic runs on Railway. The hosting region is being fixed before launch and will be named here and on the [privacy policy](/privacy), with the sub-processors we use. Providers process each request wherever they operate. Updated 2026-09-30. This page as HTML: https://arettic.com/docs/data-handling · Markdown: https://arettic.com/docs/data-handling.md · JSON: https://arettic.com/docs/data-handling.json --- # Public data and formats The no-key endpoints, the Markdown and JSON copy of every page, the discovery files, and the licence. Scores, prices, pass rules and benchmark reports are public: no key, no sign-up. Every public response is published under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) and carries `license` and `generated_at`. Use it anywhere with the attribution "Arettic (arettic.com)". ## No-key endpoints Base URL https://api.arettic.com | Endpoint | Query | What it returns | |---|---|---| | `GET /v1/task-types` | — | The task types, each with its pass rule, input and output fields. | | `GET /v1/tools` | `task_type`, `region`, `mode=test` | The tool catalog with scores and prices. `mode=test` lists the mock tools test keys buy. | | `GET /v1/tools/{id}` | — | One tool: its score per region, every score input, the weekly history, the latest benchmark and its price. | | `GET /v1/scores` | `task_type`, `region`, `mode=test` | The current score of every tool. | | `GET /v1/pricing` | — | The price per success of every tool, per plan. | | `GET /v1/formula` | — | The score formula, weights, inputs and rules, the pass rules and the price formula. See [how scores work](/docs/scores). | | `GET /v1/reports` | — | The published benchmark reports. | | `GET /v1/reports/{slug}` | — | One report: ranked results, method and sample cases. | | `GET /v1/status` | — | Live status of the API, execute, recommend, MCP, website and payments, with incidents. | | `POST /v1/waitlist` | body: `email` (required), `name`, `company`, `use_case` | Join the waitlist. 201 `joined`, 200 `already_joined`. | - Responses are cacheable for 60 seconds (`Cache-Control: public, max-age=60`) and allow any origin (`Access-Control-Allow-Origin: *`), so a browser page can call them directly. - Limit: 120 requests a minute per IP, reported in `RateLimit-*` headers. See [rate limits](/docs/rate-limits). - An unknown `task_type` gets `unknown_task_type`; an unknown tool, `404 unknown_tool`. **curl** ```bash curl "https://api.arettic.com/v1/tools?task_type=find_email®ion=US" curl https://api.arettic.com/v1/tools/zerobounce-verify-email ``` **TypeScript** ```ts import { Arettic } from "@arettic/sdk"; const arettic = new Arettic(); // public calls need no key const { tools } = await arettic.tools({ taskType: "verify_email", region: "US" }); const formula = await arettic.formula(); ``` **Python** ```python from arettic import Arettic client = Arettic() # public calls need no key tools = client.tools(task_type="verify_email", region="US")["tools"] formula = client.formula() ``` ## Every page as Markdown and JSON Every public page of the site (home, pricing, scores, each tool, each report, these docs, the changelog, status) has two machine-readable copies with the same content: - Add `.md` or `.json` to the path: `https://arettic.com/docs/quickstart.md`, `https://arettic.com/tools/zerobounce-verify-email.json`. The home page is `/index.md` and `/index.json`. - Or keep the path and send `Accept: text/markdown` or `Accept: application/json`. - Each HTML page also names its copies in `` tags. **curl** ```bash curl https://arettic.com/docs/receipts.md curl -H "Accept: application/json" https://arettic.com/pricing ``` ## Discovery files For agents and crawlers | File | What it is | |---|---| | `/llms.txt` | A short map of the site and the API for language models, with links to the Markdown copies. | | `/llms-full.txt` | The same, with the full docs inline. | | `/openapi.json` | The OpenAPI 3.1 description of every endpoint (also on the API's domain). | | `/.well-known/mcp.json` | The MCP server card: endpoint, transport and tools. | | `/schemas/{name}.json` | Open JSON Schemas: receipt, execute request and response, webhook event, pass rules. | | `/sitemap.xml` | Every public page. | | `/robots.txt` | AI crawlers and agents are welcome; only the dashboard and sign-in paths are closed. | | `/changelog.xml` | The changelog as RSS; also `/changelog.md` and `/changelog.json`. | | `/status.json` | The status page as JSON; also `/status.md`. | Updated 2026-09-30. This page as HTML: https://arettic.com/docs/public-data · Markdown: https://arettic.com/docs/public-data.md · JSON: https://arettic.com/docs/public-data.json --- # Rate limits and quotas Every limit, what it is keyed on, the headers that report it, and how to back off. Limits keep one runaway loop from hurting everyone else. They are never a CAPTCHA: every limited response says how long to wait, in headers an agent can read. ## Request rate limits Fixed windows | What | Limit | Counted per | |---|---|---| | Agent endpoints (`/v1/execute`, `/v1/recommend`, receipts, jobs, disputes, approvals, `/mcp`) | 600 requests a minute | agent key | | Org endpoints (`/v1/orgs/{orgId}/…`) | 300 requests a minute | org key or signed-in session (IP when neither) | | Public data (`/v1/tools`, `/v1/scores`, `/v1/pricing`, …) | 120 requests a minute | IP address | | Sign-in links (`POST /auth/email/start`) | 20 an hour | IP address (and at most 5 emails an hour to one address) | | Waitlist (`POST /v1/waitlist`) | 10 an hour | IP address | ## Headers Every limited endpoint answers with these headers, on success too, so you can pace yourself before you hit the wall: Rate-limit headers | Header | Value | |---|---| | `RateLimit-Limit` | Requests allowed in the window. | | `RateLimit-Remaining` | Requests left in this window. | | `RateLimit-Reset` | Seconds until the window resets. | | `RateLimit-Policy` | The limit and window, e.g. `600;w=60`. | | `Retry-After` | Only on a 429: seconds to wait before trying again. | **429 response** ```http HTTP/1.1 429 Too Many Requests RateLimit-Limit: 600 RateLimit-Remaining: 0 RateLimit-Reset: 17 RateLimit-Policy: 600;w=60 Retry-After: 17 { "error": { "code": "rate_limited", "message": "Too many requests from this agent. Try again in 17s.", "doc_url": "…/docs/errors#rate_limited", "retryable": true } } ``` ## Daily quotas `POST /v1/recommend` counts against a daily quota per org, by plan: every call is a score lookup, and a call with free text (`task`) instead of a `task_type` also counts one free-text lookup. Over the quota: `429 plan_limit`. Quotas reset at 00:00 UTC. Recommend quotas per day | Plan | Score lookups | Free-text lookups | |---|---|---| | Pay as you go | 1,000 | 100 | | Pro | 10,000 | 500 | | Max | no fixed limit | no fixed limit | - **Provider quotas.** Some providers cap how many calls one customer may make in a day. Past it, requests to that provider's tools get `429 provider_quota` (reset at 00:00 UTC); other tools for the same task keep working. - **People lookups.** At most 500 people lookups per company domain per day: `429 aup_limit`. See [data handling](/docs/data-handling#acceptable-use). - **Trial spend.** Until an org's first top-up, at most 50 trial credits an hour: `429 trial_limit`. - **Open disputes.** At most 100 open at once per org. ## Size limits Size limits | What | Limit | Over it | |---|---|---| | Request body | 1 MB | `413 payload_too_large` | | Inputs in one `execute` | 1,000 | `400 invalid_input` | | Items run straight away | 25; more runs as a [job](/docs/jobs) | — | | Job run time | 2 hours; items not yet run are released as `expired` | — | | Webhook endpoints per org | 10 | `402 plan_limit` | ## Backing off - On a 429, wait `Retry-After` seconds, then retry. Only codes with `retryable: true` are worth retrying ([error codes](/docs/errors)). - Without `Retry-After` (a 5xx or a network error), back off exponentially with jitter. - Retrying `execute` is safe only with the same `idempotency_key`: the retry returns the first answer and is never charged twice. - The SDKs do all of this for you: up to 3 retries by default, waiting out `Retry-After` up to 60 seconds, otherwise exponential backoff from 0.5 s up to 8 s with jitter, and the same idempotency key on every `execute` retry. See [SDKs](/docs/sdks#retries). Updated 2026-09-30. This page as HTML: https://arettic.com/docs/rate-limits · Markdown: https://arettic.com/docs/rate-limits.md · JSON: https://arettic.com/docs/rate-limits.json --- # Error codes Every error returns `{ error: { code, message, doc_url, retryable } }`. Retry only when `retryable` is true. | Code | HTTP | Retry | Meaning | Fix | |---|---|---|---|---| | invalid_input | 400 | no | The request body or a field is missing or malformed. | Read `message`, fix the field it names and send again. | | unauthenticated | 401 | no | No valid key or session was sent, or the key was revoked. | Send `Authorization: Bearer ` with an active key. | | forbidden | 403 | no | You're signed in but your role can't do this. | Ask an org owner. | | not_found | 404 | no | The thing you asked for doesn't exist or isn't yours. | Check the ID. | | rate_limited | 429 | yes | Too many requests in a short time. | Wait and retry. Limits are in the `RateLimit-*` headers. | | invalid_token | 401 | no | A sign-in link or invite is invalid, used or expired. | Request a new link. | | invalid_code | 400 | no | The phone verification code is wrong or expired. | Request a new code. | | phone_in_use | 409 | no | This phone number is already verified on another account. | Use a different number or sign in to the other account. | | email_unverified | 403 | no | The email address isn't verified yet. | Open the sign-in link we emailed. | | phone_unverified | 403 | no | An org needs a verified phone number first. | Verify your phone, then create the org. | | ip_not_allowed | 403 | no | The agent key has an IP allowlist and this request came from elsewhere. | Call from an allowed IP or update the allowlist. | | unknown_tool | 404 | no | No tool with that ID or slug. | List tools with `GET /v1/tools`. | | unknown_task_type | 400 | no | Not one of the seven task types. | List them with `GET /v1/task-types`. | | live_execute_unavailable | 501 | no | Live purchases aren't open yet. | Use a test key (`sk_test_…`) until launch. | | credits_frozen | 403 | no | Arettic has frozen this org's credits while we look into something on the account. | Email support. Reads still work; purchases resume once the freeze is lifted. | | insufficient_credits | 402 | no | The org's credit balance can't cover this purchase. | Top up credits or lower `max_price`. | | over_budget | 402 | no | The agent's monthly budget can't cover this purchase. | Raise the budget or wait for the next UTC month. | | approval_required | 202 | no | The purchase is over the agent's approval threshold or budget. | Retry with `approval_id` after an owner approves. Approvals expire in 24 hours. | | approval_expired | 409 | no | The approval is older than 24 hours. | Request a new approval. | | approval_rejected | 403 | no | An owner declined this purchase. | Don't retry it; ask the owner, or change the request. | | trial_limit | 429 | yes | Orgs that haven't topped up yet can spend up to 50 trial credits an hour. | Wait for the hour to pass, or top up to remove the limit. | | approval_mismatch | 409 | no | The tool, input or amount differs from what was approved. | Send exactly the approved request, or request a new approval. | | plan_limit | 402 | no | Your plan's limit on agents, seats or lookups is reached. | Upgrade the plan or wait for the daily reset. | | not_purchasable | 409 | no | The tool is listed for information only, or isn't live yet, so it can't be bought. | Pick a purchasable tool from POST /v1/recommend (purchasable: true). | | price_above_max | 402 | no | The price for this request (price × inputs) is above your max_price. | Raise max_price, send fewer inputs, or pick a cheaper tool. | | tool_paused | 409 | no | The tool is paused (outage, loss-making price or provider issue). | Use `fallback: true` or pick another tool from `recommend`. | | provider_error | 502 | yes | The provider failed after one retry. Nothing was charged. | Retry later or use `fallback: true`. | | check_failed | 200 | no | The result didn't pass the published check. Nothing was charged. | Read `reason`; try another tool or fix the input. | | request_in_progress | 409 | yes | A request with this idempotency_key is still running. | Retry in a few seconds with the same key to get its receipt. | | provider_quota | 429 | yes | Your org reached today's call limit for this provider. | Use another tool for the task, or wait for 00:00 UTC. | | aup_limit | 429 | no | The request looks like bulk collection of personal data, which provider terms forbid. | Contact support if this is legitimate research. | | idempotency_conflict | 409 | no | The idempotency key was reused with a different request. | Use a new key for a new request. | | reason_required | 400 | no | This action needs a written reason (it goes in the audit log). | Send `reason`. | | conflict | 409 | no | Something with that slug or email already exists. | Use a different value. | | unverified_email | 401 | no | Google hasn't verified this email address. | Sign in with an email link instead. | | invalid_state | 400 | no | The Google sign-in took too long or was opened in another browser. | Start Google sign-in again. | | google_failed | 401 | yes | Google didn't complete the sign-in. | Try again, or use an email link. | | not_configured | 503 | no | This feature isn't switched on in this environment. | Use another sign-in method. | | invalid_amount | 400 | no | A credit amount is zero, negative or otherwise invalid. | Send a positive whole number of credits. | | capture_exceeds_hold | 409 | no | Internal: a charge was larger than the credits held for it. It was blocked. | Nothing to do on your side; it's logged for us. | | not_a_hold | 409 | no | Internal: a settlement pointed at something that isn't a hold. It was blocked. | Nothing to do on your side; it's logged for us. | | already_settled | 409 | no | This purchase was already charged or refunded. | Fetch the receipt instead of retrying. | | invalid_credentials | 401 | no | Admin sign-in failed: email, password or code is wrong. | Check all three. Five failures lock the account for 15 minutes. | | locked | 429 | yes | Admin account locked after too many failed sign-ins. | Wait 15 minutes. | | weak_password | 400 | no | Admin passwords need at least 14 characters. | Use a longer password. | | resale_rights_missing | 409 | no | Admin: a tool can't be sold until its provider's resale rights are signed. | Record the signed agreement on the provider, or list the tool as info-only. | | price_missing | 409 | no | Admin: a tool needs a price per success before it can be sold. | Set `price_per_success_credits`. | | tool_unavailable | 409 | no | The tool can't be used for this right now: it's paused, has no price yet, or has no working provider connection. | Pick another tool, or try again later. | | adapter_not_ready | 409 | no | Admin: the tool has no working provider connection (no adapter, or its API key isn't set). | Add the provider key to .env, then run npm run providers:check. | | benchmark_missing | 409 | no | Admin: a tool must be benchmarked in the last 35 days before it can be sold. | Run a benchmark for the tool. | | below_accuracy_floor | 409 | no | Admin: the tool's latest benchmark accuracy is under the 60% floor. | Keep it info-only, or re-run after the provider improves. | | not_disputable | 409 | no | The item wasn't charged, so there's nothing to dispute. | Only charged (passed or partial) items can be disputed. | | dispute_window_closed | 409 | no | Disputes must be opened within 7 days of the purchase. | Contact support if you think this item was charged in error. | | topup_cap | 402 | no | New accounts can add up to $200 of credits in their first 14 days. | Top up a smaller amount, or wait until the account is 14 days old. | | confirmation_required | 409 | no | Admin: a large manual adjustment needs its amount confirmed. | Send the same amount again in `confirm_credits`. | | offer_unavailable | 409 | no | The founding price is used up, has ended, or this org already used it. | Subscribe at the list price, or check GET /v1/pricing for the offer's status. | | no_saved_card | 409 | no | Auto-reload needs a saved card, and this org has none. | Top up once with `save_card: true`, then turn auto-reload on. | | payment_unavailable | 502 | yes | The payment provider didn't respond as expected. | Try again in a minute. | | invalid_signature | 400 | no | A webhook's signature didn't verify, so it was ignored. | Check the webhook signing secret. | | method_not_allowed | 405 | no | That HTTP method isn't supported here (the MCP endpoint takes POST only). | Use the method in the `Allow` header. | | fx_unavailable | 503 | yes | No USD→INR exchange rate is available, so INR top-ups are paused. | Pay in USD, or try again later. | | last_owner | 409 | no | An org must keep at least one owner. | Make someone else an owner first. | | payload_too_large | 413 | no | The request body is over 1 MB. | Send large batches as async jobs of up to 1,000 items. | | internal_error | 500 | yes | Something went wrong on our side. | Retry. If it keeps happening, check the status page. | | network_error | — (SDK-side) | yes | SDK: the request never got a response (DNS, connection reset, offline). | Retry with backoff; the SDKs do this for reads and for `execute`, whose idempotency key makes a retry safe. | | timeout | — (SDK-side) | yes | SDK: the request hit the client's timeout before a response arrived. | Retry, or raise the client's timeout for long batches (or use jobs for over 25 items). | | connection_failed | — (SDK-side) | yes | MCP package: the local server couldn't reach the hosted MCP endpoint. | Check ARETTIC_API_URL and your network; the call is retried once if it certainly never left. | | aborted | — (SDK-side) | no | SDK: your own AbortSignal cancelled the request. | Nothing to fix; send the request again when you want it. | | http_error | — (SDK-side) | no | SDK: an HTTP error came back without the API's error body (a proxy, firewall or load balancer answered). The HTTP status is on the error. | Check ARETTIC_API_URL and anything between you and the API. A 429 or 5xx from a proxy is reported as `rate_limited` or `internal_error` and retried. | | unexpected_redirect | — (SDK-side) | no | SDK: the server answered with a redirect, which the SDKs never follow (it could leak your key). | Check ARETTIC_API_URL: it should be https://api.arettic.com with no path. | This page as HTML: https://arettic.com/docs/errors · Markdown: https://arettic.com/docs/errors.md · JSON: https://arettic.com/docs/errors.json --- # JSON Schemas Open specs for receipts, execute requests and responses, webhook events and pass rules, as JSON Schema 2020-12. The API serves them at https://api.arettic.com/schemas/{name}.json (index: GET https://api.arettic.com/schemas); the site mirrors them at https://arettic.com/schemas/{name}.json. Licensed CC BY 4.0. | Schema | Version | URL | Describes | |---|---|---|---| | Receipt | 1.0.0 | https://arettic.com/schemas/receipt.json | A purchase's receipt: every item attempt, its check, charge and refund. | | Execute request | 1.0.0 | https://arettic.com/schemas/execute-request.json | The body of POST /v1/execute. | | Execute response | 1.0.0 | https://arettic.com/schemas/execute-response.json | Every successful answer of POST /v1/execute: item, batch, job, approval, test mode. | | Webhook event | 1.0.0 | https://arettic.com/schemas/webhook-event.json | The body of every webhook delivery, with the data of each event type. | | Pass rules | 1.0.0 | https://arettic.com/schemas/pass-rules.json | The pass rule of every task type, as served by GET /v1/task-types and /v1/formula. | This page as HTML: https://arettic.com/schemas · Markdown: https://arettic.com/schemas.md · JSON: https://arettic.com/schemas.json --- # Changelog What changed in the Arettic API (1.0.0-pre), MCP server, SDKs, scores, pass rules, pricing and site, newest first. RSS: https://arettic.com/changelog.xml ## 2026-09-30: Open JSON Schemas and the outcome receipt spec _API, Docs_ - JSON Schemas (2020-12) for receipts, execute requests and responses, webhook events and pass rules, at `/schemas/{name}.json` (index: `/schemas`), under CC BY 4.0. - The outcome receipt spec, v1: every field, the rules, and how the hashes are made (SHA-256 of canonical JSON), so anyone can verify a receipt against the data they kept. - A Python receipt verifier next to the score formula in the open benchmark harness. ## 2026-09-30: Developer docs, changelog and status _Docs, Site_ - Docs for every part of the API: quickstart, authentication, test mode, recommend, execute, jobs, receipts, disputes, budgets and approvals, webhooks, the org API, MCP, the SDKs, scores, pass rules, credits, data handling, rate limits and public data. - Every docs page has a Markdown and a JSON copy: add `.md` or `.json` to its URL, or send `Accept: text/markdown`. - This changelog, as `/changelog.md`, `/changelog.json` and an RSS feed at `/changelog.xml`. - Terms and privacy drafts at `/terms` and `/privacy`. ## 2026-09-29: Error codes the SDKs raise themselves _SDKs, Docs_ - `network_error`, `timeout`, `aborted`, `unexpected_redirect`, `connection_failed` and `http_error` are listed on `/docs/errors`, so every `doc_url` an SDK hands you resolves. - `GET` and `DELETE` on `/mcp` answer 405 `method_not_allowed`: the MCP server is stateless and takes `POST` only. ## 2026-09-29: Ask for your stored data to be deleted _API, Dashboard_ - An owner can ask us to delete the org's stored inputs, results and private test sets from Team in the dashboard, or with `POST /v1/orgs/{orgId}/deletion-requests`. It is done within 24 hours and we email you when it is. - `GET /v1/orgs/{orgId}/deletion-requests` shows each request and when it is done by. Org keys can read requests; making one needs an owner signed in. - New error `credits_frozen`: purchases are declined while we look into something on the account; sign-in and reads keep working. ## 2026-09-29: TypeScript and Python SDKs, and a local MCP server _SDKs, MCP_ - `@arettic/sdk` (TypeScript) and `arettic` (Python) cover every agent and org endpoint, with retries on reads and on `execute` (its idempotency key makes a retry safe). - `@arettic/mcp` runs a local stdio MCP server that forwards to the hosted one, for clients that don't speak Streamable HTTP. - The hosted MCP server has all nine tools: `list_task_types`, `recommend`, `get_tool`, `execute`, `get_job`, `get_receipt`, `open_dispute`, `get_approval` and `get_balance`. ## 2026-09-29: Private benchmarks and the provider view _API, Dashboard, Scores_ - Pro orgs can upload their own test set and benchmark any tools on it: `POST /v1/orgs/{orgId}/test-sets`, then `POST /v1/orgs/{orgId}/benchmarks`. Results stay private to the org. - Providers can claim their listing and see a read-only view of their scores, failure reasons and demand: `GET /v1/orgs/{orgId}/provider`. ## 2026-09-29: Everything in the dashboard works through the API _API, Dashboard_ - Org API keys (`ok_…`, read or write, one org) drive every dashboard action. Make them on Team, or with `POST /v1/orgs/{orgId}/keys` while signed in. - The dashboard meets WCAG 2.2 AA and never shows a CAPTCHA. ## 2026-09-29: Customer dashboard, receipts and disputes _Dashboard, API_ - Spend by day, agent and tool, balance, and how many items cost you nothing: `GET /v1/orgs/{orgId}/dashboard`. - Receipt explorer with filters and a CSV export: `GET /v1/orgs/{orgId}/receipts`, `GET /v1/orgs/{orgId}/exports/receipts.csv`. - Dispute a charged item within 7 days: `POST /v1/disputes` (agent key) or `POST /v1/orgs/{orgId}/disputes`. We decide within 48 hours against the stored result; upheld means a refund to where the credits came from, and it counts against the tool's score. - Signed webhooks with 24 hours of retries, plus emails, for the single event list: `POST /v1/orgs/{orgId}/webhooks`, `GET /v1/orgs/{orgId}/events`. ## 2026-09-29: Execute: pay only for results that pass _API, Pass rules, Pricing_ - `POST /v1/execute` runs any of the 30 curated tools with one key. Each result is checked against the published pass rule for its task type: pass is charged, fail releases the hold and returns the reason, provider errors are never charged. - Up to 1,000 inputs per request; over 25 runs as a job (`GET /v1/jobs/{id}`). - Idempotency keys: a retry with the same key is never charged twice. - Opt-in fallback to the next-best tool, budgets and approval thresholds per agent (`GET /v1/approvals/{id}`), IP allowlists, and receipts for every purchase (`GET /v1/receipts/{id}`). - Inputs and results are stored encrypted for 7 days (longer only while disputed), then deleted. ## 2026-09-29: Credits, top-ups and plans _Pricing, API, Dashboard_ - Buy credits in US dollars (1 credit = $0.001): `POST /v1/orgs/{orgId}/topups`. An invoice for every top-up: `GET /v1/orgs/{orgId}/invoices`. - $1 of trial credit when you make your org; auto-reload from a saved card (`PUT /v1/orgs/{orgId}/auto-reload`). Paid credits expire after 12 months. - The Pro plan: `POST /v1/orgs/{orgId}/subscriptions`. Prices are recomputed daily from each tool's cost and pass rate; moves over 20% are reviewed before they apply (`GET /v1/pricing`). ## 2026-09-28: Recommend, scores and public data _API, Scores, MCP, Site_ - `POST /v1/recommend`: tools ranked for a task, with every score input, the price per success and the pass rule. - Weekly scores from our own benchmarks plus live results (formula at `GET /v1/formula`), published under CC BY 4.0 with no key: `GET /v1/tools`, `GET /v1/scores`, `GET /v1/task-types`. - `/llms.txt`, `/llms-full.txt`, `/openapi.json` and `/.well-known/mcp.json` for agents; a Markdown and JSON copy of every page. - Rate limits reported in `RateLimit-*` headers; a 429 carries `Retry-After`. ## 2026-09-28: Accounts, orgs and agent keys _API, Dashboard_ - Sign in with an email link or Google, verify a phone, make an org and invite your team (owners and members). - Agents with their own `sk_test_…` and `sk_live_…` keys (rotate or revoke any time). Test keys call deterministic mock providers for free. - One error envelope everywhere, `{ error: { code, message, doc_url, retryable } }`, with every code on `/docs/errors`. This page as HTML: https://arettic.com/changelog · Markdown: https://arettic.com/changelog.md · JSON: https://arettic.com/changelog.json --- # Terms of Service > Draft — not yet reviewed by a lawyer. Not in force. Draft of 2026-09-29 · Contact: hello@arettic.com These terms are the agreement between [Company legal name] ("Arettic", "we"), [registered address], GSTIN [GSTIN], and the organisation that opens an account ("you"). They cover arettic.com, the API, the MCP server and the dashboard. By creating an account, a key or a purchase you accept them, for yourself or for the company you act for. Your agents act on your keys, so what they do is your doing. ## 1. What Arettic does Arettic is a marketplace of data tools for AI agents. Your agent asks which tool works for a task (free), runs it through one key, and we check the result against the published pass rule for its task type. Only a result that passes is charged. Every tool runs on a third-party provider named on its tool page. We resell the provider's service, so its terms apply to its results as well as ours. We can add, pause or remove tools at any time. A paused tool returns tool_paused; with fallback: true we run the best other tool for the task, never for more than the paused one would have cost (or your max_price). Test keys (sk_test_) call mock providers and never spend credits: test mode is free. ## 2. Accounts, keys and agents You sign in with a one-time email link or with Google. Only owners approve purchases, make org keys and ask for data deletion. Keys are shown once and stored as hashes. Every call made with a key counts as yours until you revoke it, which is instant. You can limit a key to an IP allowlist. If we believe a key has leaked, we may revoke all your keys and email your owners; we never issue or hold the replacements. Each agent has a monthly budget ($50 by default) and an approval threshold ($20 by default); a purchase over either waits up to 24 hours for an owner's approval. We enforce both; an agent can't raise them. ## 3. Credits You buy prepaid credits: 1 credit = $0.001. The first top-up is $20, later ones $50 or more, in US dollars, each with an invoice. Credits are prepaid service for our catalog, not money: no transfer to another org, no cash-out, no refund except where the law requires one. Paid credits expire 12 months after the top-up that bought them, oldest first; owners are emailed when credits are within 30 days of expiring. A top-up that is refunded or charged back loses the credits it bought; if they were already spent, the org is blocked until it's settled. Trial credits ($1 when you sign up, $10 after a demo call) are spent first, expire after 30 days and are never refunded. One trial per phone number, email domain (per address for free-mail domains) and card; a second is removed. Until an org has paid, it can spend at most 50 credits an hour. Pro costs $99 a month or $990 a year (founding price $490 for the first year, first 50 teams, until 30 days after launch; list price after that). Team renews each period until you cancel; cancelling keeps the plan until the paid period ends. Max (from $1,000 a month) is agreed separately and set up by us. Auto-reload is opt-in: when your paid balance falls below the threshold you set, we charge your saved card the amount you chose, plus tax, with an invoice; a failed charge switches it off. [Subscription refund terms: to be drafted.] ## 4. You pay only for results that pass Each result is checked against the published pass rule for its task type; the rule's version is on the receipt. Pass: the held credits are captured and the full result returned. Fail: the hold is released and you get the reason, not the data. A web search with fewer results than asked is charged pro rata. Invalid input is rejected free before any provider call; provider errors and timeouts are never charged. Price per success = C ÷ S_price × k: the provider's list price, divided by the tool's pass rate, times your plan's multiplier (1.5, 1.3 or 1.25), rounded up to whole credits. Prices are re-set daily and shown on each tool's page; max_price caps what a request can cost. Two exceptions. So failed calls can't be used to get provider results free: if your fail rate on a tool is over 2× other customers' (or over half, where nobody else fails) across 200 or more calls, that tool is charged per call (C × k), pass or fail, never for provider errors; we email your owners, review after 30 days or 200 more calls, and lift it when your rate is back in line. And private benchmarks (Team) include 500 calls a month; calls beyond that are charged per call at the provider's list price × k, pass or fail, because we pay the provider either way. ## 5. Receipts and disputes Every purchase ends in a receipt that can't be edited: what was asked (as a hash), the tool, the check and its version, the result's hash, and what was charged and refunded. Receipts export as CSV and JSON. If a charged result is wrong, dispute it within 7 days with a reason and any evidence. We decide within 48 hours against the stored copy; if we don't, the dispute is upheld. An upheld dispute refunds the charge to your credits, never in cash, and counts against the tool's score. At most 100 disputes can be open per org. Test-mode purchases cost nothing and can't be disputed. ## 6. Acceptable use Use Arettic only for lawful purposes and as the providers' terms allow. You are responsible for having a lawful basis to look people up and for what you do with the results. Bulk collection of a company's staff isn't allowed: at most 500 people lookups (find_email, enrich_person, verify_email) per company domain per org per day; providers may cap calls per org per day too. Every refusal is recorded and reviewed. For abuse, fraud, non-payment or a blocked card we may block an org (its keys stop) or freeze its credits (purchases decline; sign-in and reads still work), and remove trial credits obtained twice. [Notice and appeal: to be decided.] ## 7. Limits and availability Rate limits: public data 120 requests a minute per IP, waitlist 10 an hour, sign-in emails 20 an hour, agent keys 600 a minute, org endpoints 300 a minute. A batch has up to 1,000 inputs; over 25 it runs as a job for up to 2 hours, and items not run by then expire uncharged. Plan limits (agents, seats, lookups a day) are enforced on our side. A tool is paused automatically when over 20% of its calls in an hour are provider errors, or it loses money over a day (20 calls or more); the status page lists incidents. A 99.9% uptime commitment is part of Enterprise agreements only. ## 8. Your data Each purchase's input and result are stored encrypted with your org's own key for 7 days (scores, disputes, the weekly audit), then deleted; an item under dispute is kept until decided, at most 48 hours more. A deletion request removes them within 24 hours; hashes, receipts and billing records stay. We never share data between customers, cache it for others or use it to train models. Details: the privacy policy. ## 9. Scores, reports and provider plans Scores, the formula, benchmark reports and the public data endpoints are open under CC BY 4.0 with attribution. Tools are ranked by score, then price; whether we sell a tool never changes its rank. A provider claims its listing with evidence we verify by hand (one org per provider) and can buy analytics (Free: $0; Insights: $199 a month; Pro: $999 a month); paying never changes a score, rank or routing decision. ## 10. Taxes and invoices Prices are in USD before tax. Indian customers pay 18% GST (CGST and SGST, or IGST), shown separately on the invoice; supplies outside India are zero-rated exports under LUT. The billing name, address, country and tax ID you enter are printed on invoices, so keep them correct. Other taxes are yours. ## 11. Liability [To be drafted by counsel.] Intent: results come from third-party providers and are guaranteed only by the pass check and the dispute process; our liability is limited to what you paid us in the 12 months before the claim; nothing excludes liability the law doesn't let us exclude. ## 12. Changes and ending We may change these terms; the date at the top changes and owners are emailed [notice period: to be decided]. You can stop at any time: revoke your keys and, if you want, ask for deletion. Unused credits aren't refunded on closure, except where the law requires it. Questions: hello@arettic.com. ## 13. Governing law [To be decided: governing law, and courts or arbitration.] This page as HTML: https://arettic.com/terms · Markdown: https://arettic.com/terms.md · JSON: https://arettic.com/terms.json --- # Privacy Policy > Draft — not yet reviewed by a lawyer. Not in force. Draft of 2026-09-29 · Contact: hello@arettic.com This policy says what [Company legal name] ("Arettic", "we"), [registered address], collects when you use arettic.com, the API, the MCP server and the dashboard, why, who sees it and how long we keep it. It covers customers, their team members and people who join the waitlist. ## 1. What we collect - Waitlist: your email and, if you give them, your name, company and use case, plus the page or link you joined from. - Account: your email, verified by a one-time link or by Google (which also gives us your name and a Google account ID); your phone number, verified by SMS code and used only so trial credits are granted once per person; your org's name, your agents' names and the emails of people you invite. - Billing: billing name, address, country and tax ID or GSTIN. From the payment provider: a card fingerprint, brand, country, risk score and whether 3-D Secure was used, never the card number. - Purchases: the inputs your agents send and the results they buy, kept encrypted with a key unique to your org for 7 days. Receipts keep hashes of inputs and results, the tool, price, outcome and refunds, not the data. - Private benchmarks: the test sets you upload, sealed with your org's key. - Provider claims: if you claim a provider listing, the evidence you give us. - Webhooks: endpoint URLs and their signing secrets (encrypted). - Connection data: the IP address and browser of each sign-in session; the IP addresses each API key is used from (to enforce your allowlist and to spot a leaked key); rate-limit counters per IP, key or session, cleared after a day. - Logs: an API request log (route, status, timing, org and agent IDs, error code; never inputs); product events tied to your user, org or agent ID (waitlist events use a hash of your email); daily usage counters (people lookups per company are keyed by a hash of the domain, so we don't keep which companies you look up); and a log of every action our staff takes on your account, including every time stored data is opened and why. - Website traffic: daily counts per page group, split into people, crawlers and agents by user agent. No IP addresses, no cookies, no fingerprinting. - Messages: the emails and SMS we send you are kept in an outbox. ## 2. Cookies Three cookies, all needed to sign in or use the dashboard, none for tracking: arettic_session, set when you sign in (HttpOnly, SameSite=Lax, 30 days or until you sign out); arettic_google, for 10 minutes during Google sign-in; and arettic_once, for 2 minutes on the dashboard page that shows a new key or secret once. There are no analytics, advertising or tracking cookies, and the site works without JavaScript. ## 3. How we use it - To run the service: recommend, execute, check and settle purchases; receipts, approvals, disputes, budgets and alerts. - To keep scores honest: stored inputs and results are re-checked when a pass rule or formula changes, reviewed when you dispute a charge, and sampled for the weekly audit. Every look is logged with who and why. - To prevent abuse: one trial per email domain (or address, for free email) and per card; the blocklist; leaked-key and spending alerts; acceptable-use incidents. - To bill you and meet tax law: invoices, credit notes and the ledger. - To email you about your account (balance, budgets, approvals, disputes, price changes, trial expiry) and, on the waitlist, the first benchmark report and your launch invite. We never sell your data, share it between customers, cache one customer's results for another or use them to train models. Public scores and reports are aggregates with no customer data in them. ## 4. Who we share it with - Railway: hosts the site, API, worker and database. [Region to fill in.] - Stripe: payments, when enabled. Stripe gets your billing details; we get the card fingerprint, brand, country and risk score. - The data providers we resell: only the provider of the tool your agent runs receives that request's input (with fallback on, the provider of the substitute tool). A private benchmark sends your test-set inputs to the providers you pick. To check a found email we may send it to our email verifier (ZeroBounce, NeverBounce or Hunter). A provider receives nothing until one of its tools is switched on. Providers with resale terms in place today: none yet. - Anthropic: the weekly audit reviewer, only when it is switched on. It receives the sampled input and result of a charged purchase and nothing else. [Confirm the DPA and this list cover it before switching it on.] - Google: only if you sign in with Google. - [Email and SMS delivery provider: to be chosen.] We disclose data when the law requires it. [Counsel to confirm the wording.] ## 5. Where your data is stored [To fill in: the Railway region for the database and the apps, and whether backups leave it.] Providers process each request wherever they operate. ## 6. How long we keep it | Data | Kept for | |---|---| | Inputs and results of purchases | 7 days; under dispute until it's decided (at most 48 hours more); deleted within 24 hours of a deletion request | | Private benchmark test sets | Until you delete them or ask for deletion | | Receipts, ledger, invoices, credit notes | As accounting records [statutory period: confirm with the CA] | | Hashes and non-personal call signals (outcome, timing, coarse segment) | Kept for scoring; they contain no inputs or results | | Sign-in sessions | Valid 30 days, or until you sign out [expired records: purge period to decide] | | Sign-in links and SMS codes | Valid 15 and 10 minutes, stored as hashes [expired records: purge period to decide] | | API request log | 14 days | | Rate-limit counters | 1 day | | Product events, staff action and data-access logs, traffic counts, the IPs each key was used from | Kept [period: to decide] | | Emails and SMS we sent you (outbox) | Kept [period: to decide] | | Waitlist entry | Until you ask to be removed | | Account and org details | While you have an account [and after: to decide] | ## 7. Your rights and controls - Access and export: everything in the dashboard is also in the API, and receipts export as CSV and JSON. - Deletion: an owner files a deletion request from the Team page (or the API, signed in). Within 24 hours we delete your org's stored inputs and results, its private test sets and its storage key, and email the person who asked. Receipts and billing records stay: they hold no inputs or results and we need them for accounting. - Keys and sessions: revoke a key or sign out at any time; both take effect at once. - Correction: change billing details on the Billing page; for anything else, email us. - [Rights under the DPDP Act 2023 and the GDPR, the grievance officer for India and response times: to be drafted by counsel.] ## 8. Security Inputs and results are encrypted per org (AES-256-GCM) with a key wrapped by a master key held in the host's secret store; a deletion request destroys the org's key. API keys, session tokens, sign-in links and SMS codes are stored only as hashes. Every access to stored data and every staff action is logged. The admin console is separate from the site and needs a password and an authenticator app. Provider API keys live in the host's secret store and are never logged. ## 9. Changes and contact We may update this policy; the date at the top changes and account owners are emailed [notice period: to be decided]. Questions and requests: hello@arettic.com. This page as HTML: https://arettic.com/privacy · Markdown: https://arettic.com/privacy.md · JSON: https://arettic.com/privacy.json --- # Mock Company Enrichment (Arettic Mock Provider) Task type: `enrich_company` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 30 credits (0.030) Pass rule `enrich_company@v1`: The record matches the requested domain (or name + country) and has name, domain, employee range and industry. Missing required fields counts as a fail. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock enrich_company, GLOBAL): 9 passed the check, 8 correct; median latency 5ms. ## Input - `domain`: company domain - `name`: company name (if no domain) - `country`: optional, with name ## Output - `name`: string - `domain`: string - `employee_range`: e.g. 51-200 - `industry`: string - `country`: optional This page as HTML: https://arettic.com/tools/mock-enrich-company · Markdown: https://arettic.com/tools/mock-enrich-company.md · JSON: https://arettic.com/tools/mock-enrich-company.json --- # Mock Person Enrichment (Arettic Mock Provider) Task type: `enrich_person` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 45 credits (0.045) Pass rule `enrich_person@v1`: Name (fuzzy ≥ 0.9) and company domain (exact) match the input; a title and one contact field are present. A match without a contact field counts as a fail. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock enrich_person, GLOBAL): 9 passed the check, 8 correct; median latency 5ms. ## Input - `first_name`: string - `last_name`: string - `company_domain`: company domain ## Output - `name`: string - `company_domain`: string - `title`: string - `email`: optional - `phone`: optional - `linkedin_url`: optional This page as HTML: https://arettic.com/tools/mock-enrich-person · Markdown: https://arettic.com/tools/mock-enrich-person.md · JSON: https://arettic.com/tools/mock-enrich-person.json --- # Mock Page Extractor (Arettic Mock Provider) Task type: `extract_url` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 3 credits (0.003) Pass rule `extract_url@v1`: HTTP 200 with at least 200 characters of main content, not a block or captcha page. A blocked page counts as a fail. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock extract_url, GLOBAL): 9 passed the check, 8 correct; median latency 5ms. ## Input - `url`: http(s) URL ## Output - `url`: string - `status_code`: number - `title`: optional - `content`: main content as text/markdown This page as HTML: https://arettic.com/tools/mock-extract-url · Markdown: https://arettic.com/tools/mock-extract-url.md · JSON: https://arettic.com/tools/mock-extract-url.json --- # Mock Email Finder (Arettic Mock Provider) Task type: `find_email` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 38 credits (0.038) Pass rule `find_email@v1`: An email is returned and its verification status is valid (not catch-all or unknown). Catch-all counts as a fail and is refunded. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 56.53 | 0.4902 | 0.5958 | 0.4902 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock find_email, GLOBAL): 9 passed the check, 8 correct; median latency 5ms. ## Input - `first_name`: string - `last_name`: string - `domain`: company domain, e.g. acme.com ## Output - `email`: string - `verification_status`: valid | invalid | catch_all | unknown - `confidence`: 0–1, optional This page as HTML: https://arettic.com/tools/mock-find-email · Markdown: https://arettic.com/tools/mock-find-email.md · JSON: https://arettic.com/tools/mock-find-email.json --- # Mock Email Verifier (Arettic Mock Provider) Task type: `verify_email` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 6 credits (0.006) Pass rule `verify_email@v1`: The verifier returns a definitive status (valid or invalid). Unknown or timeout counts as a fail. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 62.87 | 0.5958 | 0.5958 | 0.5958 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock verify_email, GLOBAL): 9 passed the check, 9 correct; median latency 5ms. ## Input - `email`: string ## Output - `email`: string - `status`: valid | invalid | catch_all | unknown - `sub_status`: optional This page as HTML: https://arettic.com/tools/mock-verify-email · Markdown: https://arettic.com/tools/mock-verify-email.md · JSON: https://arettic.com/tools/mock-verify-email.json --- # Mock Web Search (Arettic Mock Provider) Task type: `web_search` · regions: GLOBAL, US · **test mode (mock provider)** Price per success: 8 credits (0.008) Pass rule `web_search@v1`: At least N results (5 by default) with valid, non-duplicate URLs. Fewer than N is charged pro rata. ## Scores | Region | Score | A | S | P | R | L | D | Sample | Week | Formula | |---|---|---|---|---|---|---|---|---|---|---| | GLOBAL | 73.63 | 0.7225 | 0.7225 | 0.7225 | 0.7225 | 1 | 0 | 10 | 2026-09-28 | v1 | ## Latest benchmark 10 cases (Mock web_search, GLOBAL): 10 passed the check, 10 correct; median latency 5ms. ## Input - `query`: string, up to 500 characters - `n`: 1–25, default 5 ## Output - `results`: [{ url, title, snippet? }] This page as HTML: https://arettic.com/tools/mock-web-search · Markdown: https://arettic.com/tools/mock-web-search.md · JSON: https://arettic.com/tools/mock-web-search.json