{
  "page": "docs/jobs",
  "title": "Batches and jobs",
  "slug": "jobs",
  "description": "More than 25 inputs run as a job: the hold, the states, polling, and the completion webhook.",
  "section": "Buying results",
  "updated": "2026-09-29",
  "blocks": [
    {
      "type": "p",
      "text": "`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)."
    },
    {
      "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. Test keys (`sk_test_…`) never create jobs: every batch runs at once and nothing is charged; see [Test keys](#test-keys)."
    },
    {
      "type": "h2",
      "id": "sizes",
      "text": "Batch or job: the numbers"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "table",
      "caption": "What happens by the number of inputs, with a live key",
      "head": [
        "Inputs",
        "Runs",
        "Answer",
        "Concurrency and time limit"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "h2",
      "id": "submit",
      "text": "Submit a job"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "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    \"inputs\": [\n      { \"email\": \"emily@acme.com\" },\n      { \"email\": \"jason@example.com\" },\n      { \"email\": \"p3@acme.com\" }\n    ],\n    \"max_price\": 210,\n    \"idempotency_key\": \"verify-batch-2026-09-29\"\n  }'\n# ... with 30 objects in inputs, not 3. Over 25, the answer is queued."
    },
    {
      "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": "table",
      "caption": "The queued answer, field by field",
      "head": [
        "Field",
        "Meaning"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "MCP tool call",
      "lang": "json",
      "code": "{\n  \"name\": \"execute\",\n  \"arguments\": {\n    \"tool_id\": \"hunter-verify-email\",\n    \"inputs\": [{ \"email\": \"emily@acme.com\" }, { \"email\": \"jason@example.com\" }],\n    \"max_price\": 210,\n    \"idempotency_key\": \"verify-batch-2026-09-29\"\n  }\n}"
    },
    {
      "type": "h2",
      "id": "hold",
      "text": "The hold"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "h2",
      "id": "how-it-runs",
      "text": "How a job runs"
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "**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`.",
        "**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.",
        "**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.",
        "**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.",
        "**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`.",
        "**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."
      ]
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "h2",
      "id": "states",
      "text": "Job states"
    },
    {
      "type": "p",
      "text": "`status` on the job answer is one of five values. `queued` and `running` mean keep polling; the other three are final and never change."
    },
    {
      "type": "table",
      "caption": "Job states",
      "head": [
        "status",
        "Meaning",
        "What the answer carries"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "h2",
      "id": "poll",
      "text": "Poll the job"
    },
    {
      "type": "p",
      "text": "`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`."
    },
    {
      "type": "code",
      "title": "curl",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21 \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\""
    },
    {
      "type": "code",
      "title": "TypeScript",
      "lang": "ts",
      "code": "import { Arettic, AretticApiError } from \"@arettic/sdk\";\n\nconst arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY });\n\nconst inputs = Array.from({ length: 30 }, (_, i) => ({ email: `p${i}@acme.com` }));\nconst bought = await arettic.execute(\n  { tool_id: \"hunter-verify-email\", inputs, max_price: 210 },\n  { idempotencyKey: \"verify-batch-2026-09-29\" },\n);\n\nif (bought.status === \"queued\") {\n  // One poll, if you want to show progress yourself:\n  const now = await arettic.job(bought.job_id);\n  console.log(now.status, now.progress, \"of\", bought.items);\n\n  // Or let the SDK poll every 2 s for up to 10 minutes (both are options):\n  try {\n    const job = await arettic.waitForJob(bought.job_id, { intervalMs: 2_000, timeoutMs: 30 * 60_000 });\n    console.log(job.status, job.summary, job.charged?.credits, job.receipt_id);\n    if (Array.isArray(job.items)) {\n      for (const item of job.items) console.log(item.index, item.status, item.result ?? item.reason);\n    }\n  } catch (err) {\n    // code \"timeout\": the job is still running; call waitForJob again later. Nothing is cancelled.\n    if (err instanceof AretticApiError && err.code === \"timeout\") console.log(\"still running\");\n    else throw err;\n  }\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\ninputs = [{\"email\": f\"p{i}@acme.com\"} for i in range(30)]\nbought = client.execute(\n    {\"tool_id\": \"hunter-verify-email\", \"inputs\": inputs, \"max_price\": 210},\n    idempotency_key=\"verify-batch-2026-09-29\",\n)\n\nif bought[\"status\"] == \"queued\":\n    # One poll, if you want to show progress yourself:\n    now = client.job(bought[\"job_id\"])\n    print(now[\"status\"], now[\"progress\"], \"of\", bought[\"items\"])\n\n    # Or let the SDK poll every 2 s for up to 600 s (both are arguments):\n    try:\n        job = client.wait_for_job(bought[\"job_id\"], interval=2.0, timeout=1800.0)\n    except AretticApiError as err:\n        if err.code != \"timeout\":\n            raise\n        print(\"still running\")  # call wait_for_job again later; nothing is cancelled\n    else:\n        print(job[\"status\"], job.get(\"summary\"), job.get(\"receipt_id\"))\n        for item in job[\"items\"]:\n            print(item[\"index\"], item[\"status\"], item.get(\"result\") or item.get(\"reason\"))"
    },
    {
      "type": "p",
      "text": "Over MCP the tool is `get_job`. Its only argument is `job_id`, and the result is the same JSON as the REST answer."
    },
    {
      "type": "code",
      "title": "MCP tool call",
      "lang": "json",
      "code": "{\n  \"name\": \"get_job\",\n  \"arguments\": { \"job_id\": \"3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\" }\n}"
    },
    {
      "type": "code",
      "title": "Response (example): while it runs",
      "lang": "json",
      "code": "{\n  \"job_id\": \"3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\",\n  \"status\": \"running\",\n  \"items\": 30,\n  \"progress\": 12,\n  \"created_at\": \"2026-09-29T09:12:45.118Z\",\n  \"started_at\": \"2026-09-29T09:12:47.402Z\",\n  \"finished_at\": null\n}"
    },
    {
      "type": "p",
      "text": "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:"
    },
    {
      "type": "code",
      "title": "Response (example): done",
      "lang": "json",
      "code": "{\n  \"job_id\": \"3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\",\n  \"status\": \"done\",\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\": \"p3@acme.com\", \"status\": \"valid\" }\n    }\n  ],\n  \"progress\": 30,\n  \"created_at\": \"2026-09-29T09:12:45.118Z\",\n  \"started_at\": \"2026-09-29T09:12:47.402Z\",\n  \"finished_at\": \"2026-09-29T09:13:21.977Z\",\n  \"receipt_id\": \"8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f\",\n  \"tool_id\": \"hunter-verify-email\",\n  \"task_type\": \"verify_email\",\n  \"check_version\": \"v1\",\n  \"charged\": { \"credits\": \"189\", \"usd\": \"0.189\" },\n  \"refunded\": { \"credits\": \"21\", \"usd\": \"0.021\" },\n  \"summary\": { \"items\": 30, \"passed\": 27, \"partial\": 0, \"failed\": 3 }\n}"
    },
    {
      "type": "code",
      "title": "Response (example): expired before any item ran",
      "lang": "json",
      "code": "{\n  \"job_id\": \"b7e1d0c9-2f3a-4b5c-8d6e-7f8a9b0c1d2e\",\n  \"status\": \"expired\",\n  \"items\": [\n    {\n      \"index\": 0,\n      \"status\": \"failed\",\n      \"charged\": { \"credits\": \"0\", \"usd\": \"0.000\" },\n      \"reason\": \"expired\"\n    }\n  ],\n  \"progress\": 30,\n  \"created_at\": \"2026-09-29T07:00:02.511Z\",\n  \"started_at\": \"2026-09-29T07:00:04.090Z\",\n  \"finished_at\": \"2026-09-29T09:00:04.731Z\",\n  \"receipt_id\": \"c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f\",\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\": \"210\", \"usd\": \"0.210\" },\n  \"summary\": { \"items\": 30, \"passed\": 0, \"partial\": 0, \"failed\": 30 }\n}"
    },
    {
      "type": "h2",
      "id": "fields",
      "text": "The job answer, field by field"
    },
    {
      "type": "table",
      "caption": "GET /v1/jobs/{job_id} fields",
      "head": [
        "Field",
        "When",
        "Meaning"
      ],
      "rows": [
        [
          "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)."
        ]
      ]
    },
    {
      "type": "h2",
      "id": "receipt",
      "text": "One receipt per job"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "code",
      "title": "curl",
      "lang": "bash",
      "code": "curl https://api.arettic.com/v1/receipts/8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f \\\n  -H \"Authorization: Bearer $ARETTIC_API_KEY\""
    },
    {
      "type": "h2",
      "id": "webhook",
      "text": "The job.completed webhook"
    },
    {
      "type": "p",
      "text": "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."
    },
    {
      "type": "code",
      "title": "Webhook request (example)",
      "lang": "http",
      "code": "POST /hooks/arettic HTTP/1.1\nHost: example.com\nContent-Type: application/json\nUser-Agent: Arettic-Webhooks/1.0\nArettic-Event-Id: 5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d\nArettic-Event-Type: job.completed\nArettic-Signature: t=1790759602,v1=4f0d2c9b8a7e6d5c4b3a2918f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4\n\n{\n  \"id\": \"5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d\",\n  \"type\": \"job.completed\",\n  \"created_at\": \"2026-09-29T09:13:22.004Z\",\n  \"data\": {\n    \"job_id\": \"3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21\",\n    \"status\": \"done\",\n    \"receipt_id\": \"8d2f4a6c-1b3e-4c5d-9e7f-0a1b2c3d4e5f\",\n    \"item_count\": 30,\n    \"passed\": 27,\n    \"expired\": 0\n  }\n}"
    },
    {
      "type": "table",
      "caption": "job.completed data fields",
      "head": [
        "Field",
        "Meaning"
      ],
      "rows": [
        [
          "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."
        ]
      ]
    },
    {
      "type": "p",
      "text": "The signature is `t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\">` 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."
    },
    {
      "type": "h2",
      "id": "with-other-fields",
      "text": "Approvals, max_price, idempotency and fallback"
    },
    {
      "type": "list",
      "items": [
        "**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."
      ]
    },
    {
      "type": "h2",
      "id": "test-keys",
      "text": "Test keys and jobs"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "h2",
      "id": "errors",
      "text": "Errors"
    },
    {
      "type": "p",
      "text": "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)."
    },
    {
      "type": "table",
      "caption": "Errors on jobs",
      "head": [
        "code",
        "HTTP",
        "When"
      ],
      "rows": [
        [
          "[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."
        ]
      ]
    },
    {
      "type": "code",
      "title": "Response (example): a job that isn't yours",
      "lang": "json",
      "code": "{\n  \"error\": {\n    \"code\": \"not_found\",\n    \"message\": \"Job not found\",\n    \"doc_url\": \"https://arettic.com/docs/errors#not_found\",\n    \"retryable\": false\n  }\n}"
    },
    {
      "type": "p",
      "text": "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)."
    }
  ]
}