{
  "page": "docs/execute",
  "title": "Execute",
  "slug": "execute",
  "description": "Run a tool on one input or a batch, and pay only for items that pass the published check.",
  "section": "Buying results",
  "updated": "2026-09-29",
  "blocks": [
    {
      "type": "p",
      "text": "`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."
    },
    {
      "type": "note",
      "text": "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)."
    },
    {
      "type": "h2",
      "id": "request",
      "text": "The request"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "table",
      "caption": "Execute request fields",
      "head": [
        "Field",
        "Type",
        "Meaning"
      ],
      "rows": [
        [
          "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)."
        ]
      ]
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "table",
      "caption": "Input fields and the free pre-call check, per task type",
      "head": [
        "task_type",
        "Input fields",
        "Checked before the call"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "h2",
      "id": "steps",
      "text": "What happens to a request"
    },
    {
      "type": "p",
      "text": "Every live request goes through the same steps, in this order. Nothing is held until step 4, so anything refused before that is free."
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "**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.",
        "**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`.",
        "**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.",
        "**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.",
        "**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.",
        "**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.",
        "**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.",
        "**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`.",
        "**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."
      ]
    },
    {
      "type": "h2",
      "id": "examples",
      "text": "Examples"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "code",
      "title": "curl",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/execute \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"tool_id\": \"hunter-verify-email\",\n    \"input\": { \"email\": \"jason@acme.com\" },\n    \"max_price\": 10,\n    \"idempotency_key\": \"verify-jason-1\"\n  }'"
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { Arettic, AretticApiError } from \"@arettic/sdk\";\n\n// Reads ARETTIC_API_KEY (and ARETTIC_API_URL) from the environment when you leave them out.\nconst arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY });\n\ntry {\n  const bought = await arettic.execute(\n    { tool_id: \"hunter-verify-email\", input: { email: \"jason@acme.com\" }, max_price: 10 },\n    { idempotencyKey: \"verify-jason-1\" }, // else the SDK makes a UUID for this call\n  );\n  switch (bought.status) {\n    case \"passed\":\n    case \"partial\":\n      console.log(bought.result, bought.charged.credits); // \"7\"\n      break;\n    case \"failed\":\n      console.log(\"not charged:\", bought.reason);\n      break;\n    case \"approval_required\":\n      console.log(\"waiting for an owner:\", bought.approval_id);\n      break;\n    case \"queued\": // only when inputs has more than 25 items\n      console.log((await arettic.waitForJob(bought.job_id)).summary);\n      break;\n  }\n} catch (err) {\n  // Anything the API refused: err.code is a code from /docs/errors, e.g. \"price_above_max\".\n  if (err instanceof AretticApiError) console.error(err.status, err.code, err.message);\n  else throw err;\n}"
    },
    {
      "type": "code",
      "title": "Python",
      "lang": "python",
      "code": "from arettic import Arettic, AretticApiError\n\nclient = Arettic()  # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set)\n\ntry:\n    bought = client.execute(\n        {\n            \"tool_id\": \"hunter-verify-email\",\n            \"input\": {\"email\": \"jason@acme.com\"},\n            \"max_price\": 10,\n        },\n        idempotency_key=\"verify-jason-1\",  # else the SDK makes a UUID for this call\n    )\nexcept AretticApiError as err:\n    # Anything the API refused: err.code is a code from /docs/errors, e.g. \"price_above_max\".\n    print(err.status, err.code, err.message)\n    raise\n\nstatus = bought[\"status\"]\nif status in (\"passed\", \"partial\"):\n    print(bought[\"result\"], bought[\"charged\"][\"credits\"])  # \"7\"\nelif status == \"failed\":\n    print(\"not charged:\", bought[\"reason\"])\nelif status == \"approval_required\":\n    print(\"waiting for an owner:\", bought[\"approval_id\"])\nelif status == \"queued\":  # only when inputs has more than 25 items\n    print(client.wait_for_job(bought[\"job_id\"])[\"summary\"])"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "MCP tool call",
      "lang": "json",
      "code": "{\n  \"name\": \"execute\",\n  \"arguments\": {\n    \"tool_id\": \"hunter-verify-email\",\n    \"input\": { \"email\": \"jason@acme.com\" },\n    \"max_price\": 10,\n    \"idempotency_key\": \"verify-jason-1\"\n  }\n}"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"passed\",\n  \"execution_id\": \"c02576cf-b3ce-4b0f-a30c-6e8aad4328ce\",\n  \"receipt_id\": \"67a1fb46-c554-4cf6-b4e2-df1dbdf0e936\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"refunded\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" }\n}"
    },
    {
      "type": "h2",
      "id": "statuses",
      "text": "Every response status"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "table",
      "caption": "Execute statuses",
      "head": [
        "status",
        "HTTP",
        "When",
        "What comes with it"
      ],
      "rows": [
        [
          "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)."
        ]
      ]
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "h3",
      "id": "status-failed",
      "text": "failed"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"failed\",\n  \"execution_id\": \"9b1f0d2e-4c6a-4e8b-9f3d-2a7c5e1b8d40\",\n  \"receipt_id\": \"0d2e7f31-8a4b-4c9d-b1e6-5f7a9c3d2e10\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"refunded\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"reason\": \"status:unknown\"\n}"
    },
    {
      "type": "h3",
      "id": "status-partial",
      "text": "partial (pro rata)"
    },
    {
      "type": "p",
      "text": "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:"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"partial\",\n  \"execution_id\": \"5e8c1a7b-2d3f-4a6e-8b9c-1f2e3d4c5b6a\",\n  \"receipt_id\": \"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d\",\n  \"tool_id\": \"exa-web-search\",\n  \"task_type\": \"web_search\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"4\", \"usd\": \"0.004\" },\n  \"refunded\": { \"credits\": \"6\", \"usd\": \"0.006\" },\n  \"result\": {\n    \"results\": [\n      { \"url\": \"https://example.com/saas-directory\", \"title\": \"US SaaS companies\", \"snippet\": \"A directory of…\" },\n      { \"url\": \"https://acme.com/blog/saas-in-the-us\", \"title\": \"SaaS in the US\", \"snippet\": \"The market…\" }\n    ]\n  },\n  \"reason\": \"results:2/5\"\n}"
    },
    {
      "type": "h3",
      "id": "status-completed",
      "text": "completed (a batch of 25 or fewer)"
    },
    {
      "type": "p",
      "text": "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:"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"completed\",\n  \"receipt_id\": \"7c6b5a4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"14\", \"usd\": \"0.014\" },\n  \"refunded\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"summary\": { \"items\": 3, \"passed\": 2, \"partial\": 0, \"failed\": 1 },\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"passed\",\n      \"charged\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n      \"result\": { \"email\": \"emily@acme.com\", \"status\": \"valid\" }\n    },\n    {\n      \"index\": 1,\n      \"status\": \"failed\",\n      \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n      \"reason\": \"status:unknown\"\n    },\n    {\n      \"index\": 2,\n      \"status\": \"passed\",\n      \"charged\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n      \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" }\n    }\n  ]\n}"
    },
    {
      "type": "h3",
      "id": "status-queued",
      "text": "queued (a batch of more than 25)"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"queued\",\n  \"job_id\": \"3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\",\n  \"items\": 30,\n  \"max_charge\": { \"credits\": \"210\", \"usd\": \"0.210\" },\n  \"poll\": \"/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\",\n  \"message\": \"Running 30 items as a job. Poll the job for progress; each item is charged only if it passes.\"\n}"
    },
    {
      "type": "h3",
      "id": "status-approval-required",
      "text": "approval_required"
    },
    {
      "type": "p",
      "text": "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:"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"approval_required\",\n  \"approval_id\": \"b4c3d2e1-f0a9-4b8c-7d6e-5f4a3b2c1d0e\",\n  \"reason\": \"over_threshold\",\n  \"amount\": { \"credits\": \"14\", \"usd\": \"0.014\" },\n  \"expires_at\": \"2026-09-30T18:04:11.512Z\",\n  \"message\": \"This is above the agent's approval threshold. An owner has been asked to approve it; retry with approval_id once approved.\"\n}"
    },
    {
      "type": "h2",
      "id": "fields",
      "text": "Response fields"
    },
    {
      "type": "p",
      "text": "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[]`."
    },
    {
      "type": "table",
      "caption": "Execute response fields",
      "head": [
        "Field",
        "Meaning"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "h2",
      "id": "reason-codes",
      "text": "Reason codes"
    },
    {
      "type": "p",
      "text": "`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."
    },
    {
      "type": "table",
      "caption": "Reason codes on failed and partial items",
      "head": [
        "reason",
        "Task types",
        "Meaning"
      ],
      "rows": [
        [
          "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:<value>",
          "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:<field>",
          "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:<found>/<n>",
          "web_search",
          "Partial: fewer valid, distinct URLs than the `n` you asked for. Charged `price × found ÷ n`, rounded up."
        ],
        [
          "http:<status>, 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."
        ]
      ]
    },
    {
      "type": "h2",
      "id": "test-keys",
      "text": "Test keys"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "Response (example): test key, one input",
      "lang": "json",
      "code": "{\n  \"test_mode\": true,\n  \"tool_id\": \"mock-find-email\",\n  \"task_type\": \"find_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"status\": \"passed\",\n  \"result\": { \"email\": \"emily.carter@acme.com\", \"verification_status\": \"valid\" },\n  \"would_have_charged\": { \"credits\": \"38\", \"usd\": \"0.038\" }\n}"
    },
    {
      "type": "p",
      "text": "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`."
    },
    {
      "type": "h2",
      "id": "batches",
      "text": "Batches"
    },
    {
      "type": "p",
      "text": "`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."
    },
    {
      "type": "list",
      "items": [
        "**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`."
      ]
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "curl: poll a job",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21 \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\""
    },
    {
      "type": "h2",
      "id": "max-price",
      "text": "max_price"
    },
    {
      "type": "p",
      "text": "`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."
    },
    {
      "type": "code",
      "title": "Response (example): 2 inputs at 7 credits with max_price 13",
      "lang": "json",
      "code": "{\n  \"error\": {\n    \"code\": \"price_above_max\",\n    \"message\": \"This costs up to 14 credits (7 × 2), above your max_price of 13.\",\n    \"doc_url\": \"https://arettic.com/docs/errors#price_above_max\",\n    \"retryable\": false\n  }\n}"
    },
    {
      "type": "p",
      "text": "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`."
    },
    {
      "type": "h2",
      "id": "idempotency",
      "text": "Idempotency"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "list",
      "items": [
        "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."
      ]
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "code",
      "title": "Response (example): the same key sent again",
      "lang": "json",
      "code": "{\n  \"status\": \"passed\",\n  \"execution_id\": \"c02576cf-b3ce-4b0f-a30c-6e8aad4328ce\",\n  \"receipt_id\": \"67a1fb46-c554-4cf6-b4e2-df1dbdf0e936\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"refunded\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n  \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" },\n  \"replayed\": true\n}"
    },
    {
      "type": "code",
      "title": "Response (example): the same key while the first request is still running",
      "lang": "json",
      "code": "{\n  \"error\": {\n    \"code\": \"request_in_progress\",\n    \"message\": \"A request with this idempotency_key is still running. Retry in a few seconds to get its receipt.\",\n    \"doc_url\": \"https://arettic.com/docs/errors#request_in_progress\",\n    \"retryable\": true\n  }\n}"
    },
    {
      "type": "h2",
      "id": "fallback",
      "text": "Fallback"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "table",
      "caption": "Fallback fields on an item",
      "head": [
        "Field",
        "Meaning"
      ],
      "rows": [
        [
          "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`."
        ]
      ]
    },
    {
      "type": "code",
      "title": "curl",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/execute \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"tool_id\": \"hunter-verify-email\",\n    \"input\": { \"email\": \"jason@acme.com\" },\n    \"fallback\": true,\n    \"idempotency_key\": \"verify-jason-2\"\n  }'"
    },
    {
      "type": "p",
      "text": "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:"
    },
    {
      "type": "code",
      "title": "Response (example)",
      "lang": "json",
      "code": "{\n  \"status\": \"passed\",\n  \"execution_id\": \"d7e6f5a4-b3c2-4d1e-9f0a-8b7c6d5e4f30\",\n  \"receipt_id\": \"e8f7a6b5-c4d3-4e2f-8a1b-9c0d1e2f3a41\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"6\", \"usd\": \"0.006\" },\n  \"refunded\": { \"credits\": \"7\", \"usd\": \"0.007\" },\n  \"result\": { \"email\": \"jason@acme.com\", \"status\": \"valid\" },\n  \"served_by\": \"zerobounce-verify-email\",\n  \"attempts\": [\n    {\n      \"execution_id\": \"2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d\",\n      \"tool_id\": \"hunter-verify-email\",\n      \"status\": \"failed\",\n      \"reason\": \"status:unknown\",\n      \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" }\n    },\n    {\n      \"execution_id\": \"d7e6f5a4-b3c2-4d1e-9f0a-8b7c6d5e4f30\",\n      \"tool_id\": \"zerobounce-verify-email\",\n      \"status\": \"passed\",\n      \"charged\": { \"credits\": \"6\", \"usd\": \"0.006\" }\n    }\n  ]\n}"
    },
    {
      "type": "p",
      "text": "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`."
    },
    {
      "type": "h2",
      "id": "approvals",
      "text": "Approvals"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "curl: resend with the approval",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/execute \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"tool_id\": \"hunter-verify-email\",\n    \"inputs\": [{ \"email\": \"emily@acme.com\" }, { \"email\": \"jason@acme.com\" }],\n    \"approval_id\": \"b4c3d2e1-f0a9-4b8c-7d6e-5f4a3b2c1d0e\"\n  }'"
    },
    {
      "type": "h2",
      "id": "per-call-pricing",
      "text": "Per-call pricing"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "h2",
      "id": "errors",
      "text": "Errors"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "code",
      "title": "Response (example): declined",
      "lang": "json",
      "code": "{\n  \"status\": \"declined\",\n  \"error\": {\n    \"code\": \"insufficient_credits\",\n    \"message\": \"This needs 1050 credits ($1.050); the org has 1000 ($1.000). Top up to continue.\",\n    \"doc_url\": \"https://arettic.com/docs/errors#insufficient_credits\",\n    \"retryable\": false\n  }\n}"
    },
    {
      "type": "table",
      "caption": "Error codes execute can answer, in the order they are checked",
      "head": [
        "Code",
        "HTTP",
        "When"
      ],
      "rows": [
        [
          "[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."
        ]
      ]
    },
    {
      "type": "p",
      "text": "`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."
    },
    {
      "type": "h2",
      "id": "next",
      "text": "Where next"
    },
    {
      "type": "list",
      "items": [
        "[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."
      ]
    }
  ]
}