> ## Documentation index
> Fetch the complete documentation index at: https://docs.htspilot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Asynchronous work

> Lookups and batches run in the background - the 202 answer, the states of a lookup and of a batch, how to poll, and webhooks as the alternative.

Classification does not happen inside the request that asks for it. The
request stores the work and answers at once; the result is read afterwards, by polling or through a webhook.

## A lookup

`POST /api/classify` answers `202 Accepted` with the lookup in the state `queued`. The same holds for a rerun
(`POST /api/lookups/{lookup_id}/rerun`), which answers `202` with a new lookup.

| `status` | Meaning | Final |
| --- | --- | --- |
| `queued` | Stored, waiting for a worker | No |
| `processing` | Being classified. `progress` names the stage | No |
| `proposed` | A code is proposed with enough support | Yes |
| `needs_review` | A code is proposed, and a person should check it. `review_flags` say why | Yes |
| `needs_info` | The description does not decide between candidates. `missing_info` and `result.questions` say what to add; a rerun with the answers continues it | Yes |
| `error` | The classification failed. `error` holds the message; the lookup is not charged | Yes |

A lookup is finished when its status is neither `queued` nor `processing`. After that the status does not change
by itself. A reviewer's decision is recorded next to it, in `decision` (`approved`, `overridden`, `rejected`) and
`final_code`; it does not replace `status`.

While a lookup is `processing`, `progress` describes the run: the current `stage`, its `index` out of `total`,
and each stage with its state (`done`, `running`, `skipped`, `pending`). It is `null` before the run starts and
after it ends.

### Polling a lookup

Read `GET /api/lookups/{lookup_id}` until the status is final:

```python
import json, os, time, urllib.request

BASE = "https://htspilot.com/api"
HEADERS = {"X-API-Key": os.environ["HTS_PILOT_API_KEY"], "Content-Type": "application/json"}

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    request = urllib.request.Request(BASE + path, data=data, headers=HEADERS, method=method)
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)

lookup = call("POST", "/classify", {"description": "Cotton terry bath towel, 70 x 140 cm", "market": "US"})
delay = 1.0
while lookup["status"] in ("queued", "processing"):
    time.sleep(delay)
    delay = min(delay * 1.5, 10)
    lookup = call("GET", f"/lookups/{lookup['id']}")
print(lookup["status"], lookup["recommended_code"])
```

- Start at about one second and let the pause grow to ten.
- Stop after a few minutes and treat the lookup as still pending: read it again later, or rely on the webhook.
  Do not create it a second time: that is a second lookup.
- Do not poll many lookups in a tight loop. For volume, use a batch or a webhook.

## A batch

A batch is a file of products. It is uploaded, checked, started, and then processed row by row in the
background ([Batches](/batches.md)).

| `status` | Meaning | Final |
| --- | --- | --- |
| `uploaded` | The file is stored and its rows were read. Nothing is classified yet | No |
| `queued` | Started, waiting for a worker | No |
| `running` | Rows are being classified | No |
| `completed` | Every row is done | Yes |
| `completed_with_errors` | Finished, and at least one row failed. `POST /api/batches/{batch_id}/retry` runs the failed rows again | Yes |

A batch has no status `error`: a failure belongs to a row. `error` is a status of a row (the row's `error` holds
the message), and a batch with such rows ends as `completed_with_errors`.

### Polling a batch

Poll `GET /api/batches/{batch_id}/progress`, which is small:

```json
{
  "id": "6a84a87996544d39b99b76e83d59486c",
  "status": "completed",
  "updated_at": "2026-10-03T19:48:03.747413",
  "total_rows": 3,
  "processed_rows": 3,
  "failed_rows": 0,
  "error": null,
  "counts": {
    "done": 3
  },
  "version": "73af12f0adf70935"
}
```

`version` is a token that changes whenever the batch or one of its rows changed. Keep the last one you saw and
read the rows again (`GET /api/batches/{batch_id}/rows`) only when it differs. The token is opaque: compare it,
do not parse it. A pause of two to five seconds between polls is enough.

## Webhooks instead of polling

Register a webhook and the API calls you when the work is done ([Webhooks](/webhooks.md)):

| Event | Sent when |
| --- | --- |
| `lookup.completed` | A lookup created by `POST /api/classify` or a rerun finished with a result: `proposed`, `needs_review` or `needs_info`. A lookup that ends in `error` sends no event, and neither do the rows of a batch |
| `batch.completed` | A batch reached `completed` or `completed_with_errors` |
| `lookup.decided` | A reviewer approved, overrode or rejected a lookup |

The event carries the ids and the outcome, not the whole object: read the lookup or the batch for the details.
Because a failed lookup sends no event and a delivery can fail for good after its retries, keep a slow poll as a
safety net for work that has been pending for longer than expected.
