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.
Arettic is pre-launch. Keys go to design partners first; everyone else joins the 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.
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.
| 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 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 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": "[email protected]" },
{ "email": "[email protected]" },
{ "email": "[email protected]" }
],
"max_price": 210,
"idempotency_key": "verify-batch-2026-09-29"
}'
# ... with 30 objects in inputs, not 3. Over 25, the answer is queued.{
"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."
}| 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.
{
"name": "execute",
"arguments": {
"tool_id": "hunter-verify-email",
"inputs": [{ "email": "[email protected]" }, { "email": "[email protected]" }],
"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
- 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 answersrequest_in_progress. - Claimed. The worker looks for work every 2 seconds and takes the oldest queued job. Status
running,started_atis 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, withinmax_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 asexpired, and the job's status becomesexpiredinstead ofdone. - Finished. One receipt is written for the job. The encrypted inputs are deleted.
finished_atis set,progressequalsitems, and the org gets onejob.completedevent.
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.
| 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); 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 https://api.arettic.com/v1/jobs/3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21 \ -H "Authorization: Bearer $ARETTIC_API_KEY"
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;
}
}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.
{
"name": "get_job",
"arguments": { "job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21" }
}{
"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:
{
"job_id": "3f9c2c1e-6d7a-4b8f-a2e1-9c0b5d4e3f21",
"status": "done",
"items": [
{
"index": 0,
"status": "passed",
"charged": { "credits": "7", "usd": "0.007" },
"result": { "email": "[email protected]", "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": "[email protected]", "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 }
}{
"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
| Field | When | Meaning |
|---|---|---|
| job_id | always | The job's UUID. |
| status | always | queued, running, done, expired or failed. See Job 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. |
| 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 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. |
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 a charged item within 7 days. Everything on the receipt is on Receipts.
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.
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
}
}| 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=<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. 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 isapproval_required(HTTP 202), nothing is held, and an owner is emailed. Once approved, send exactly the same tool and inputs withapproval_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. - max_price. A cap on the whole job, in credits.
price × itemsover the cap refuses the request withprice_above_maxbefore anything is held. Withfallback: true, each item may fall back to a tool priced at or undermax_price ÷ items.max_chargeon the queued answer isprice × items, never more thanmax_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 getsrequest_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, withreplayed: true. The same key with a different request isidempotency_conflict. - Fallback.
fallback: trueworks 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 showsserved_byand bothattempts.progresscounts 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.
Errors
Submitting a job can be refused for every reason a request can: those codes are on Execute. 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.
| code | HTTP | When |
|---|---|---|
| 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 | 401 | No Authorization: Bearer sk_… header, or the key was revoked. |
| 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 | 402 | The org's balance can't cover price × items for the whole job. Top up or send fewer inputs. |
| 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 | 402 | price × items is above max_price. Raise the cap or send fewer inputs. |
| 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 | 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. |
{
"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. The SDK helpers waitForJob and wait_for_job, with their timeouts and errors, are on SDKs.