Skip to the content

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:

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).

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
error The batch itself failed. error holds the message Yes

Polling a batch

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

{
  "id": "9058ee5650844af9b9d6f473bdf3c544",
  "status": "completed",
  "updated_at": "2026-10-03T16:07:52.103466",
  "total_rows": 3,
  "processed_rows": 3,
  "failed_rows": 0,
  "error": null,
  "counts": {
    "done": 3
  },
  "version": "f3bb25533d548f36"
}

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.

Register a webhook and the API calls you when the work is done (Webhooks):

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.