# HTS Pilot API: the whole documentation > HTS Pilot proposes tariff codes from a product description for three import markets (United States, European Union, Vietnam), with the reasons, the sources and a review queue. This is the documentation of its HTTP API: single lookups, batch files, a SKU catalog, tariff data and duty estimates, usage, review decisions and signed webhooks. Source: https://docs.htspilot.com/. Index: https://docs.htspilot.com/llms.txt # Introduction > What the HTS Pilot API does, its base URL, a first request that works, and how these pages are organised. The HTS Pilot API proposes tariff codes from a product description for three import markets, the United States (HTS), the European Union (CN and TARIC) and Vietnam (the Vietnamese import tariff, AHTN), with the reasons, the sources it used and a review queue. It covers single lookups, batch files, a SKU catalog that can be kept in step with an ERP or PIM, tariff data and duty estimates, usage, and the reviewer's decision on a lookup. A result is a suggestion for reference. It is not a classification decision by a customs authority: the declarant and the authority decide. Duty and landed cost figures are estimates. ## Base URL ``` https://htspilot.com/api ``` Every path in these pages starts with `/api`. Requests and responses are JSON (`Content-Type: application/json`) unless an operation says otherwise: a batch is uploaded as `multipart/form-data`, and the templates, the batch results and the credit ledger are downloaded as Excel or CSV files. Use HTTPS. ## A first request Create an API key in the application ([Authentication](/authentication.md)), then ask who the key belongs to: ```bash curl https://htspilot.com/api/auth/me \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` ```json { "id": "a7ba4a2440c7422fb695a1fd80d65ce5", "name": "Administrator (API: Documentation examples)", "email": "admin@example.com", "role": "admin", "role_label": "Admin", "via": "api_key", "avatar_url": "", "credits": 0, "unlimited": true } ``` `via` is `api_key` when the request was recognised by its key. `GET /api/health` needs no key and tells whether the service is up. ## Classify a product A lookup runs in the background. Create it, then read it until it is finished ([Asynchronous work](/asynchronous-work.md)): ```bash curl -X POST https://htspilot.com/api/classify \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{"description": "Knitted cotton T-shirt for men, short sleeves, crew neck", "origin": "VN", "market": "US"}' ``` The answer is `202 Accepted` with the lookup, its `status` still `queued` (shortened here: the full object is described in [Lookups](/lookups.md)): ```json { "id": "f4a2251d5dc941ec99f7d459e05082a5", "created_at": "2026-10-03T19:48:02.932457", "kind": "single", "market": "US", "dataset_version": "2026-SAMPLE", "status": "queued", "recommended_code": null, "confidence": 0.0, "language": "en", "progress": null } ``` Then read it: ```bash curl https://htspilot.com/api/lookups/f4a2251d5dc941ec99f7d459e05082a5 \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` When `status` is no longer `queued` or `processing`, `recommended_code` holds the proposed code and `result` the candidates, the reasons, the sources and what is still missing. A lookup can be charged to the account ([Rate limits and quotas](/rate-limits.md#credits)). ## How these pages are organised First the rules that hold for every call: - [Authentication](/authentication.md): API keys, roles, `401` and `403`. - [Errors](/errors.md): the error body and the status codes in use. - [Request identifiers](/request-identifiers.md) - [Rate limits and quotas](/rate-limits.md): credits, plan limits and the few per-minute limits. - [Pagination](/pagination.md) - [Languages](/languages.md): `Accept-Language` and the answer language of a lookup. - [Versioning and changes](/versioning.md) - [Asynchronous work](/asynchronous-work.md): `202`, states and polling. - [Webhooks](/webhooks.md): events, payloads, signatures, retries. Then one page per resource, each with its object and its operations: [Lookups](/lookups.md), [Review](/review.md), [Batches](/batches.md), [Catalog](/catalog.md), [Tariff data](/tariff-data.md), [Usage](/usage.md), [Webhook endpoints](/webhook-endpoints.md), [Reports](/reports.md) and [System](/system.md). Two more things sit next to these pages. The [API explorer](/explorer/) sends requests to the live API with your key, from your browser. The [OpenAPI specification](/openapi.json) is the machine-readable description of every operation, with its parameters and schemas. ## About the examples The example answers in these pages were captured from a local run of the service, not from production. The US answers use a small sample tariff (its version is labelled `2026-SAMPLE`), so codes, rates and texts in them illustrate the shape of an answer and are not tariff information. Figures such as credit balances are illustrative. A long list in an example is cut to its first two entries. Ids are 32 hexadecimal characters for lookups, batches, datasets and webhooks, and whole numbers for SKUs, alerts and webhook deliveries. # Authentication > API keys for the HTS Pilot API - where to create one, how to send it, what a role allows, and what 401 and 403 answers look like. Every request but `GET /api/health` needs an API key, sent in the `X-API-Key` header: ```bash curl https://htspilot.com/api/usage/me \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` ## Creating a key An administrator creates keys in the application, under Admin, API keys: give the key a name and a role. The key is shown once, when it is created; only its first characters are kept for display afterwards. A key starts with `hts_`. It can be revoked at any time on the same page, and a revoked key answers `401` from then on. Keys are not created through this API: the key management routes belong to the application and are not part of the customer API. ## Roles A key has one of three roles, and acts for the account that created it: | Role | May | | --- | --- | | `entry` (shown as "user") | Create and read its own lookups and batches, use the catalog, read tariff data and usage | | `reviewer` | Everything above for all lookups and batches, plus decisions, deleting a SKU, acknowledging an alert, reports | | `admin` | Everything above, plus webhooks | - The role of a key never exceeds the role of the account that created it. If the account is later given a lower role, the key is cut to that role too. - With the `entry` role a key sees only the lookups and batches of its own account; one of another account answers `404`. Reviewers and admins see all of them. - The plan of the account that created the key applies to the key ([Rate limits and quotas](/rate-limits.md)). - An operation that needs more than the `entry` role says so in its description. `GET /api/auth/me` returns the account and the effective role behind a key ([System](/system.md)). ## Sessions are not for integrations The web application signs in with a session token sent as `Authorization: Bearer `. Those sign-in and account routes are not part of the customer API and are not described here. Integrations use an API key. ## What 401 and 403 look like No key: `401`. ```json { "detail": "Please sign in", "code": "unauthenticated" } ``` A key that does not exist or was revoked: `401`. A key whose owner account was deactivated also answers `401`, with the message "The API key owner has been disabled". ```json { "detail": "Invalid API key", "code": "unauthenticated" } ``` A valid key whose role does not allow the call: `403`. ```json { "detail": "Admin role or higher required", "code": "forbidden" } ``` A valid key whose plan does not include the feature, or would exceed a plan limit: `403`, with `details` ([Errors](/errors.md#plan-refusals)). ## Keeping a key safe - A key is a secret with the rights of its role. Keep it in a secret store or an environment variable, never in source code, a URL, a log line or a browser page. - Call the API from your servers. A key in front-end code is readable by everyone who opens the page. - Give each integration its own key with the lowest role that works, so one can be revoked without stopping the others. - If a key may have leaked, revoke it and create a new one. There is no way to read a key again after it was created. - The [API explorer](/explorer/) keeps the key you type in memory for that tab only; it is gone when the page is reloaded. # Errors > The error body of the HTS Pilot API with its machine-readable code, the status codes in use, validation errors, plan refusals and how messages follow Accept-Language. An error is answered with an HTTP status of `4xx` or `5xx` and a JSON body. It is never a `200` with an error inside. ## The error body ```json { "detail": "Lookup not found", "code": "not_found" } ``` | Field | Type | Meaning | | --- | --- | --- | | `detail` | string | What went wrong, written for a person, in the language of the request | | `code` | string | What went wrong, for a program. In every error the API writes | | `request_id` | string | Only on a `500`: the value of the `X-Request-ID` header of the answer | | `details` | object | More about the error, when there is more to say. Absent otherwise | | `errors` | array | Only on a `422` for invalid input: one entry per invalid field, see below | Branch on `code` and show `detail`. `code` is lower snake case, the same in every language, and is not changed once it is published. `detail` is a sentence: it is translated ([Languages](/languages.md)) and its wording can change. Every answer also carries the header `X-Request-ID` ([Request identifiers](/request-identifiers.md)). ## Error codes Most errors carry the code of their status. A few are more specific: | `code` | Status | Meaning | | --- | --- | --- | | `bad_request` | `400` | The request cannot be carried out as it was sent | | `unauthenticated` | `401` | No valid API key was sent, or it was not accepted | | `payment_required` | `402` | The call needs something the account has not paid for | | `out_of_credits` | `402` | The credits of the account are used up | | `forbidden` | `403` | The call is refused for this caller, usually because of its role | | `plan_feature` | `403` | The plan of the account does not include this function. `details.key` names it | | `plan_limit` | `403` | The call goes over a limit of the plan. `details` has the limit and what was asked | | `not_found` | `404` | The address or the record does not exist, or belongs to another user | | `method_not_allowed` | `405` | The address exists and does not take this method | | `conflict` | `409` | The state of the record does not allow the call | | `payload_too_large` | `413` | The body or the file sent is larger than the API accepts | | `validation_error` | `422` | The input is not valid. `errors` or `details` say which field | | `rate_limited` | `429` | Too many calls in a short time. `Retry-After`, when the answer has it, says how many seconds to wait | | `internal_error` | `500` | An unexpected failure. The body also carries `request_id` | | `upstream_error` | `502` | A service this API depends on failed or did not answer | | `service_unavailable` | `503` | The function is not available at the moment | More specific codes may be added later ([Versioning and changes](/versioning.md)). Treat a code you do not know like the status it came with. An answer produced before the request reached the API (a `502`, `503` or `504` during an outage or a release, or a refusal at the edge of the network) may have another body, or none, and no `code`: fall back on the HTTP status. ## Status codes | Status | When | | --- | --- | | `200` | The call succeeded | | `202` | A lookup was created and is being classified in the background ([Asynchronous work](/asynchronous-work.md)) | | `400` | The request cannot be processed as sent | | `401` | No valid API key was sent ([Authentication](/authentication.md)) | | `402` | The account that owns the work cannot be charged for it. Whether this can occur depends on the account ([Rate limits and quotas](/rate-limits.md#credits)) | | `403` | The role of the key, or the plan of the account, does not allow the call | | `404` | The object does not exist, or belongs to another account and the key has the `entry` role | | `405` | The address exists and does not take this method | | `409` | The state of the object does not allow the call: a batch that is already running, a decision that is not there to reopen | | `413` | The body or the file sent is larger than the API accepts | | `422` | The input is not valid: a field failed validation, or a rule of the operation was not met | | `429` | Too many requests of a kind that is limited per minute | | `500` | An unexpected error on our side. The body carries a general message, `code` and `request_id` | | `502` | A service the call depends on did not answer: the classification engine or an official source | | `503` | A function is not available at the moment | A lookup that fails while it is being classified is not an HTTP error: the lookup ends with the status `error` and a message in its `error` field ([Asynchronous work](/asynchronous-work.md)). ## Validation errors When a field of the request fails validation, the answer is `422` with one entry per field: ```json { "detail": "Invalid input", "code": "validation_error", "errors": [ { "field": "description", "message": "String should have at least 1 character" } ] } ``` `field` is the path of the field in the request (`items.0.sku` for the first item of a list). The `message` of an entry comes from the validation library and is in English. When a rule of the operation is not met, the answer is also `422`, with `detail` alone or with `details`: ```json { "detail": "Enter the code to finalize", "code": "validation_error" } ``` A webhook URL that is refused names the field and the reason: `"details": { "field": "url", "reason": "webhook_private" }`. ## State conflicts ```json { "detail": "The lookup is not finalized", "code": "conflict" } ``` `409` means the request was understood and the object is in a state that does not allow it. Read the object again before deciding what to do. ## Plan refusals A feature the plan of the account does not include, or a limit it would exceed, answers `403` with the code `plan_feature` or `plan_limit` and a `details` object that names the entitlement. The code is repeated in `details.code`, where it was before `code` existed: | `code` | Fields of `details` | Meaning | | --- | --- | --- | | `plan_feature` | `key` | The plan does not include the feature `key` (for example `feature.webhooks`) | | `plan_limit` | `key`, `limit`, `requested` | The call would make `requested`, and the plan allows `limit` (for example `batch_max_rows`) | `GET /api/plans/me` lists what the plan allows ([Usage](/usage.md)). ## Language of the messages `detail` follows the `Accept-Language` header of the request, and the answer names its language in `Content-Language`: ```bash curl -i https://htspilot.com/api/usage/me -H "Accept-Language: vi" ``` ``` HTTP/1.1 401 Unauthorized Content-Language: vi X-Request-ID: fbefced8d1434ceb9407bf612a8b008b {"detail":"Vui lòng đăng nhập","code":"unauthenticated"} ``` ## Handling errors - Treat `401` and `403` as configuration problems: do not retry them. - `402`: when it occurs, stop sending work that creates lookups until the account can be charged again. Do not retry it. - `404`, `409`, `422`: fix the request; the same request will fail again. - `429`: wait a minute, or the number of seconds in `Retry-After` when the header is there, then retry. - `500`, `502`, `503`, `504` and network errors: retry with a growing pause, and log `X-Request-ID`. Reads are safe to repeat. Before repeating a call that creates something, check whether the first one went through: there is no idempotency key, so a repeated `POST /api/classify` creates a second lookup, which can be charged a second time. `POST /api/skus`, and `POST /api/skus/bulk` without `classify_missing`, update an existing SKU and are safe to repeat; with `classify_missing`, a repeat classifies again every SKU that still has no code. # Request identifiers > Every answer of the HTS Pilot API carries an identifier in X-Request-ID - what it is, how to send a reference of your own, and what to quote when asking for help. Every answer the API writes, successful or not, carries the header `X-Request-ID`: an identifier the API makes for that one call, 32 hexadecimal characters. It names one request, and it is what to quote when you report a problem. ``` HTTP/1.1 404 Not Found Content-Language: en X-Request-ID: 6d2d12f6e4ef4ec18f5588be89fa12dc {"detail":"Lookup not found","code":"not_found"} ``` The API always makes this identifier itself. A caller cannot choose it. ## Sending a reference of your own To match a call with your own logs, send your own reference in the request header `X-Request-ID`. It does not replace the identifier of the API: it comes back in a second header, `X-Client-Request-ID`, and is recorded next to the call. ```bash curl -i https://htspilot.com/api/usage/me \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "X-Request-ID: order-sync-2026-10-03.0042" ``` ``` HTTP/1.1 200 OK X-Request-ID: 08fe1d6ee53049d8a3c3df42f9bccfd8 X-Client-Request-ID: order-sync-2026-10-03.0042 ``` | Header | Direction | Value | | --- | --- | --- | | `X-Request-ID` | Answer | The identifier the API made for this call: 32 hexadecimal characters. On every answer the API writes | | `X-Request-ID` | Request, optional | Your own reference: 8 to 64 characters of letters, digits, `.`, `_` and `-`, the first one a letter or a digit | | `X-Client-Request-ID` | Answer | Your reference, returned as sent. Only when you sent a valid one | A reference of any other shape is ignored without an error: the call runs, and no `X-Client-Request-ID` comes back. The reference is yours: it need not be unique, and the API never uses it to recognise a repeated call (there is no idempotency key, see [Errors](/errors.md#handling-errors)). ## Unexpected failures An unexpected failure answers `500` and repeats the identifier in the body, so that it survives in a log that keeps bodies and drops headers: ```json { "detail": "System error, please try again later", "code": "internal_error", "request_id": "5f0c2a9e4b7d4e0f9a1b2c3d4e5f6a7b" } ``` (The shape as the specification gives it: no `500` could be captured for these pages.) `request_id` is in the body of a `500` only. For every other answer, read the header. ## Answers that have no identifier An answer that was produced before the request reached the API may carry neither the header nor a `code`: a `502`, `503` or `504` during an outage or a release, or a refusal at the edge of the network. Fall back on the HTTP status then, and quote the time of the call. ## What to quote when asking for help - the value of `X-Request-ID` from the answer; that alone is enough to find the call; - without it: the method and path, the time of the request in UTC to the second, and the HTTP status; - never the API key. Its first characters, as the application shows them, identify it. Log `X-Request-ID` with every failed call, and with successful ones where you can afford it. ## Other identifiers | Where | Identifier | Use | | --- | --- | --- | | A webhook delivery | `X-HTS-Delivery` header, the same number as `id` in the body | Ignore a delivery you have already processed ([Webhooks](/webhooks.md)) | | A lookup | `id` | Everything that happened to it is in its `events` | | A batch row | `row_index`, with `lookup_id` once it was classified | Follow a row from the file to its lookup | | A SKU | `external_ref` | Your own reference (an ERP or PIM id), stored and returned as sent | # Rate limits and quotas > What limits the use of the HTS Pilot API - credits, the limits of a plan, the few per-minute limits, and the size limits of a request. The API has no general limit of requests per minute, per key or per plan, and returns no rate limit headers (no `X-RateLimit-*`). What limits use is listed here: credits, the plan of the account, a few limits on specific operations, and the size of a request. Send requests at a pace your integration needs, and keep to the polling guidance in [Asynchronous work](/asynchronous-work.md). Work that is classified (lookups, batch rows) is queued and processed in the background, so sending more at once does not make it finish sooner. ## Credits Credits are counted against the account that owns the work: for a call made with an API key, the account that created the key. Whether a call is charged, and whether it can be refused with `402`, depends on that account's role and plan, and administrator accounts are exempt today. Write your client to handle `402` as described in [Errors](/errors.md#handling-errors) rather than to assume a balance. Where credits apply: - `POST /api/classify`, a rerun, classifying one SKU and starting a batch can answer `402` with the code `out_of_credits` when the balance does not cover the lookups the call would create. The `detail` names the balance and what the call needs. - A lookup is charged to the account that owns it when it finishes. A lookup that ends with the status `error` is not charged. - `POST /api/skus/bulk` with `classify_missing` never answers `402`: it answers `200` with `classification_queued`, and the lookups are created afterwards, in the background. A lookup that cannot be charged is not created, and the SKU stays without a suggested code. Read the SKUs to see which were classified. - A batch that runs out of balance while it runs marks the remaining rows as failed with the reason. `GET /api/usage/me` returns the balance (`credits`) and whether the account is exempt (`unlimited`) ([Usage](/usage.md)). The figures in the example answers are illustrative. ## Plan limits The plan of the account that created the key decides which features are on and how large some things may be. An account without a plan is not restricted. A refusal is a `403` with the code `plan_feature` or `plan_limit` ([Errors](/errors.md#plan-refusals)). The limits enforced on API calls today: | Entitlement | What it gates | | --- | --- | | `feature.batch` | Uploading, starting, retrying and editing a batch, and classifying the SKUs of a bulk sync. Finished batches stay readable and exportable | | `batch_max_rows` | The number of rows of a batch file, checked at upload and again at start, and the number of SKUs a bulk sync classifies | | `feature.webhooks` | Creating, changing and testing a webhook | | `feature.reports` | The productivity report | `GET /api/plans/me` returns every entitlement of the plan with its value. Plans are described on . ## Limits per minute Three limits apply to specific operations: | What | Limit | Answer | | --- | --- | --- | | Comments on lookups (`action: "comment"` of a decision) | 20 per minute per account, across all lookups | `429` with `Retry-After: 60` | | Comments on one lookup | 500 in all | `409` | | Test events of a webhook (`POST /api/webhooks/{hook_id}/test`) | 5 per minute per webhook, 20 per minute per administrator | `429` with `details.retry_after_seconds: 60` | ## Size limits | What | Limit | | --- | --- | | `description` of a lookup or a SKU | 2000 characters | | `material`, `use`, `composition` | 500 characters each | | `notes` | 1000 characters | | Items in one `POST /api/skus/bulk` | 5000 | | `page_size` of a list | 200, or 500 for SKUs ([Pagination](/pagination.md)) | | A batch file | Limited in megabytes and in rows by the service, and in rows by the plan (`batch_max_rows`, checked at upload and at start) | # Pagination > How lists are paged in the HTS Pilot API - page and page_size on the paged lists, limit and offset on the others, and the shape of each answer. Lists come in two forms. The larger ones are paged with `page` and `page_size` and answer with an object that carries the total. The smaller ones take a `limit` and answer with a plain array. There are no cursors and no `Link` headers. ## Paged lists | Operation | `page_size` default | `page_size` maximum | | --- | --- | --- | | `GET /api/lookups` | 20 | 200 | | `GET /api/review` | 20 | 200 | | `GET /api/skus` | 50 | 500 | | `GET /api/usage/ledger` | 25 | 200 | | Parameter | Type | Meaning | | --- | --- | --- | | `page` | integer, from 1 | The page to return. Default 1 | | `page_size` | integer | Items per page | The answer: ```json { "items": [], "total": 0, "page": 1, "page_size": 20 } ``` | Field | Type | Meaning | | --- | --- | --- | | `items` | array | The objects of this page | | `total` | integer | How many objects match the filters, on all pages | | `page`, `page_size` | integer | What was asked for | The SKU list adds `counts` (SKUs per code status) and the ledger adds `totals`. A page past the end answers `200` with an empty `items` and the same `total`. To read everything, ask for `page` 1, 2, 3 and so on until `page * page_size >= total`. The lookup lists are ordered by `sort`: `newest` (the default), `oldest`, `confidence` (least certain first) or `risk` (highest first). New lookups arrive while you page through a list ordered by `newest`, so an item can appear on two pages; filter with `date_from` and `date_to`, or sort by `oldest`, when a complete pass matters. ## Lists with a limit | Operation | Parameters | Default | Maximum | Answer | | --- | --- | --- | --- | --- | | `GET /api/batches` | `limit` | 20 | 100 | Array, newest first | | `GET /api/batches/{batch_id}/rows` | `offset`, `limit` | 200 | 2000 | Array, in file order | | `GET /api/alerts` | `limit` | 200 | 1000 | Array | | `GET /api/datasets/{dataset_id}/entries` | `limit` | 50 | 500 | Array | | `GET /api/hts/search` | `limit` | 50 | 200 | Object with `items` | | `GET /api/tariff-changes` | `limit` | 200 | 2000 | Object with `items` | | `GET /api/webhooks/{hook_id}/deliveries` | none | - | - | The last 50 deliveries | These answers carry no total. For the rows of a batch, `total_rows` of the batch is the count, and `offset` walks through them. ## Values outside the range A `page` below 1, or a `page_size` or `limit` above its maximum, answers `422` with the field named in `errors` ([Errors](/errors.md#validation-errors)). The value is not silently cut to the maximum. # Languages > The two language settings of the HTS Pilot API - Accept-Language for labels and messages, and the answer language of a lookup, set with the language field of a classify request. Two settings decide the language of what the API returns. `Accept-Language` is per request and governs labels and messages. The answer language is per lookup and governs the text written for that lookup. Supported languages: English (`en`), Vietnamese (`vi`), Japanese (`ja`) and Korean (`ko`). ## Accept-Language: the language of the reader Send `Accept-Language` with one of the four codes. A tag with a region is read by its language (`en-US` is `en`), quality values are honoured, and the first supported language wins. Without the header, or with no supported language in it, the service answers in its default language. The answer names its language in `Content-Language`. ```bash curl https://htspilot.com/api/lookups/f4a2251d5dc941ec99f7d459e05082a5 \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Accept-Language: ja" ``` What follows `Accept-Language`: - error messages (`detail`); - status and decision labels (`status_label`, `decision_label`), role labels, country names; - review flags: the `message` of each entry of `review_flags` and of `result.flags`; - the names of the checks, the disclaimer and the legal notes of a result; - the Excel templates and the Excel results of a batch. The machine values next to a label do not change with the language: `status`, `decision`, the `code` of a review flag. Branch on those, show the labels. ## The answer language of a lookup A lookup has a language of its own, returned as `language`. It is set when the lookup is created: 1. the `language` field of `POST /api/classify`, when it is sent (`en`, `vi`, `ja` or `ko`; a tag with a region is read by its language); 2. otherwise the language of the request, from `Accept-Language`. A rerun (`POST /api/lookups/{lookup_id}/rerun`) keeps the language of the lookup it continues, unless the request names another `language`. ```bash curl -X POST https://htspilot.com/api/classify \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{"description": "Stainless steel vacuum flask, 500 ml", "market": "US", "language": "ko"}' ``` What is written in the answer language, for every reader, whatever their `Accept-Language`: - the written reasons for choosing or ruling out a code, the summary and the open uncertainties; - the missing information (`missing_info`); - the questions with their options (`result.questions`); - the legal findings and the warnings. So a lookup created in Korean reads in Korean to everyone, while its review flags and status labels appear in the language of whoever reads it. ## Which follows which | Part of a lookup | Follows | | --- | --- | | `status_label`, `decision_label` | The reader (`Accept-Language`) | | `review_flags`, `result.flags` | The reader | | Names of checks, disclaimer, legal notes | The reader | | Reasons, summary, uncertainties | The lookup (`language`) | | `missing_info`, `result.questions` and their options | The lookup | | Legal findings, warnings | The lookup | | Official tariff descriptions | Not translated: as published | This changed: questions, missing information and legal findings used to follow `Accept-Language` on every read. A client that relied on the header for them now sets `language` when it creates the lookup. ## Notes - A product description can be written in English, Vietnamese, Japanese or Korean, whatever the answer language. - Texts that live in the tariff data and the legal notes exist in Vietnamese and English; Japanese and Korean readers get the English text. - An integration that stores results for one team should set `language` explicitly on every classify request, so the stored text does not depend on the headers of whichever client created it. # Versioning and changes > How the HTS Pilot API changes - one live version for everyone, dated records of the published contract, what counts as a breaking change and how a client keeps working. The service runs one version of the API, the latest, for every client. There is no version in the path, no version header and nothing to pin: a request cannot ask for the behaviour of an earlier date. The OpenAPI document carries `1.0.0` as its `info.version`; that number describes the document and does not select behaviour. ## Dated versions are records Every time the published contract changes, the OpenAPI document of that day is kept as a dated version, and the [changelog](/changelog/) gets an entry: a summary written by a person, and the exact difference against the version before (operations, parameters, fields and status codes that were added, changed, deprecated or removed, each marked when it breaks existing clients). The version select of these pages shows the reference as it was on a date: its operations, parameter tables and examples. That is a record of what was published then, for reading the history and for comparing with what an integration was built against. It does not change what the service does: the API always serves the latest version, and the [API explorer](/explorer/) always calls the live API. A version records the contract, not its wording. A change to paths, operations, parameters, schemas, types, required fields, lists of values, status codes, headers or authentication gets a new dated version and a changelog entry. A change to descriptions, summaries or example text alone does not: the newest version is refreshed in place. ## What can change without notice Changes that a well-written client is not affected by are made as part of normal releases, and appear in the changelog as "added": - a new operation, a new optional parameter, a new optional field of a request; - a new field in an answer, at any depth; - a new value in a list of values that grows with the product: review flag codes, webhook events, entitlement keys, kinds of alert, error codes; - the wording of a message (`detail`, labels, the text of a flag), which is not part of the recorded contract. ## What is a breaking change Removing or renaming an operation, a parameter, a field or a response header; changing the type or the meaning of a field; making an optional parameter or request field required; letting an answer leave out a field it always had; no longer accepting a value a request could send; changing the status code of a success, or what authenticates a call. An error status added to or dropped from the list of an operation is not breaking. A breaking change is recorded in the changelog, marked as breaking, in the version in which it was released. One change from before the history began is on record in [Languages](/languages.md): the questions, the missing information and the legal findings of a lookup moved from the language of the reader to the language of the lookup. There is no deprecation header, no fixed notice period and no commitment to keep an earlier behaviour available today. The changelog, with its feed at `/changelog.xml`, is where changes are announced. ## Writing a client that keeps working - Ignore fields you do not know. Do not fail on an unknown value of a status, a flag code or an event name: treat it as "other". - Branch on machine values (`status`, `decision`, the `code` of an error, the `code` of a flag), never on message text. Treat an error code you do not know like its HTTP status. - Follow the changelog feed, and read `/openapi.json` on this host when you need the current contract: it is generated from the running code. - Verify webhook signatures over the raw bytes, so a new field in a payload does not break verification. ## Tariff data has versions of its own Separately from the API, each tariff schedule is loaded as a version (`dataset_version`, for example a revision of the US HTS). A lookup records the version it was classified against, `GET /api/datasets` lists the loaded versions, and `GET /api/tariff-changes` returns what changed between two of them ([Tariff data](/tariff-data.md)). # 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. # Webhooks > Signed notifications from HTS Pilot to your system - creating a webhook, the events and their payloads, verifying the signature, retries and which URLs are accepted. A webhook is a URL of yours that the API calls with an HTTP `POST` when something happened: a lookup or a batch finished, a reviewer decided, a tariff schedule changed. It replaces polling ([Asynchronous work](/asynchronous-work.md)). ## Creating a webhook Webhooks need the `admin` role, and the plan of the account must include them. Create one in the application under Admin, Webhooks, or with the API ([Webhook endpoints](/webhook-endpoints.md)): ```bash curl -X POST https://htspilot.com/api/webhooks \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{"url": "https://erp.example.com/hooks/hts-pilot", "events": ["lookup.completed", "lookup.decided"]}' ``` The answer contains `secret`, the key of the signature. It is returned only by this call: store it. Leave `events` empty to receive every event. A webhook belongs to the whole service account, not to one user: it receives the events of every lookup and batch, whoever created them. ## Events | Event | Sent when | `data` | | --- | --- | --- | | `lookup.completed` | A lookup created by `POST /api/classify` or by a rerun finished with a result (`proposed`, `needs_review` or `needs_info`). Not sent for a lookup that ends in `error`, for the rows of a batch, or for a lookup made from the catalog | The lookup's outcome, see below | | `lookup.decided` | A reviewer approved, overrode or rejected a lookup. Reopening a decision and commenting send no event | The same fields, with `decision` and `final_code` | | `batch.completed` | A batch reached `completed` or `completed_with_errors` | `batch_id`, `filename`, `status`, `total_rows`, `counts`, `market`, `dataset_version` | | `tariff.changed` | A new version of a tariff schedule became active | `market`, `from_version`, `to_version`, and the number of lines `added`, `removed`, `description_changed`, `duty_changed`, and `sku_alerts` | | `sku.alert` | A SKU of the catalog is affected by a new tariff version | `alert_id`, `sku_id`, `kind`, `message` | | `ping` | You asked for a test delivery (`POST /api/webhooks/{hook_id}/test`) | Empty object | `GET /api/webhooks/events` returns the event names with their labels. New events can be added ([Versioning and changes](/versioning.md)): ignore the ones you do not handle. ## The delivery Every event has the same envelope. This is a real `lookup.completed` delivery from the examples, with its headers: ``` POST /hooks/hts-pilot HTTP/1.1 Content-Type: application/json X-HTS-Event: lookup.completed X-HTS-Delivery: 1 X-HTS-Signature: sha256=5fed75736f279bb863b793d316c20085c02764f99991f1eb1d5abd926f26fdc5 User-Agent: HTS-Pilot-webhook/1.0 ``` ```json {"event":"lookup.completed","created_at":"2026-10-03T19:48:02.994920+00:00","data":{"lookup_id":"f4a2251d5dc941ec99f7d459e05082a5","status":"proposed","recommended_code":"6109.10.00.14","confidence":0.75,"market":"US","dataset_version":"2026-SAMPLE","decision":null,"final_code":null,"sku_id":null,"batch_id":null,"row_index":null},"id":1} ``` | Field | Type | Meaning | | --- | --- | --- | | `event` | string | The event name, also in `X-HTS-Event` | | `created_at` | string | When the event was created, ISO 8601 in UTC | | `data` | object | What happened; its fields depend on the event | | `id` | integer | The delivery id, also in `X-HTS-Delivery`. The same for every attempt of one delivery | The `data` of the two lookup events: | Field | Type | Meaning | | --- | --- | --- | | `lookup_id` | string | Read the lookup with `GET /api/lookups/{lookup_id}` | | `status` | string | `proposed`, `needs_review` or `needs_info` | | `recommended_code` | string or null | The proposed code | | `confidence` | number | From 0 to 1 | | `market`, `dataset_version` | string | The market and the tariff version it was classified against | | `decision`, `final_code` | string or null | The reviewer's decision and the code it settled on, once there is one | | `sku_id`, `batch_id`, `row_index` | integer, string, integer, or null | The SKU or the batch row the lookup belongs to | The body is compact JSON in UTF-8. The payload names ids and outcomes; it does not carry the full lookup. ## Verifying the signature `X-HTS-Signature` is `sha256=` followed by the hexadecimal HMAC-SHA256 of the raw request body, keyed with the webhook secret. Compute it over the bytes you received, before parsing them, and compare in constant time. Python: ```python import hashlib import hmac def is_from_hts_pilot(secret: str, raw_body: bytes, signature_header: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header or "") ``` JavaScript (Node.js): ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; export function isFromHtsPilot(secret, rawBody, signatureHeader) { const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")); const received = Buffer.from(signatureHeader ?? ""); return expected.length === received.length && timingSafeEqual(expected, received); } ``` To test your code: with the secret `0123456789abcdef0123456789abcdef0123456789abcdef` (a sample, in use nowhere) and the body above, exactly as printed on its one line, the signature is the one in the header above. - Read the raw body. A framework that parses JSON and serialises it again changes the bytes and the signature no longer matches. - Reject a request without a valid signature with `401` and do nothing else with it. - The signature covers the body only. It carries no timestamp: use `id` to recognise a delivery you have already processed. ## Answering, retries and failures Answer with a `2xx` status as soon as the delivery is stored on your side, and do the work afterwards. - An attempt times out after 10 seconds. Any answer that is not `2xx`, a timeout or a connection error is a failure. - A delivery is attempted up to 3 times: the second attempt follows 2 seconds after the first failure and the third 4 seconds after the second. After the third failure the delivery is recorded as failed and is not sent again. - Redirects are not followed: a `3xx` answer is a failure. - At most two deliveries to one webhook run at the same time. A burst of events is delivered in turn, and under very heavy load a delivery can be recorded as not sent instead of waiting without end. - Deliveries are not ordered, and one event can arrive more than once. Use `id` to ignore a repeat, and read the object when the order matters. `GET /api/webhooks/{hook_id}/deliveries` lists the last 50 deliveries with the status your endpoint answered, the number of attempts and the error. The webhook itself shows the outcome of the latest one in `last_status` and `last_error`. There is no automatic replay of a failed delivery: read the objects you missed with the list operations. ## Which URLs are accepted The URL is checked when the webhook is created or changed, and again before every attempt: - `https` only; - no user name or password in the URL; - the host must be a public name or address. A name of a private network, a name without a dot, `localhost`, and any name that resolves to a loopback, private, link-local, carrier-grade NAT or otherwise non-public address (IPv4 or IPv6) is refused. A name that does not resolve is refused the same way. A refused URL answers `422` with `details.field` set to `url`. The delivery connects to the address that was checked, with your host name in the `Host` header and in the TLS handshake, so the certificate must be valid for that name. ## Testing `POST /api/webhooks/{hook_id}/test` sends a `ping` now, signed like any other event, and answers with the result (`ok`) and the webhook. It is limited to 5 calls per minute per webhook. During development, expose your local receiver through a public `https` tunnel: a private address is refused. # Lookups > Classify one product and read the result - the lookup object, the recommended code, the other candidates, the reasons, the sources and what is still missing. A lookup is one product description classified against one tariff schedule. It is created by `POST /api/classify`, runs in the background ([Asynchronous work](/asynchronous-work.md)) and ends with a proposed code, a request for more information, or a request for review. A result is a suggestion for reference, not a classification decision by a customs authority. ## The lookup object Lists, and `POST /api/lookups/{lookup_id}/resolve`, return the summary fields. Classify, get, rerun and the decision return the summary fields and the detail fields. Summary fields: | Field | Type | Meaning | | --- | --- | --- | | `id` | string | The id of the lookup | | `created_at` | string | When it was created, ISO 8601, UTC | | `kind` | string | `single`, `batch` for a row of a batch, or `sku` for a lookup made from the catalog | | `batch_id`, `row_index` | string, integer, or null | The batch and the row it belongs to | | `parent_lookup_id` | string or null | The lookup this one reruns | | `description` | string | The product description that was sent | | `market` | string | The import market: `US`, `EU` or `VN` ([Tariff data](/tariff-data.md#markets)) | | `dataset_version` | string | The version of the tariff schedule it was classified against | | `status` | string | `queued`, `processing`, `proposed`, `needs_review`, `needs_info` or `error` | | `status_label` | string | The status, in the language of the reader | | `recommended_code` | string or null | The proposed code, formatted as the schedule writes it | | `recommended_description` | string or null | The official description of that line, with its parents | | `hs6` | string or null | The first six digits | | `confidence` | number | From 0 to 1 | | `review_flags` | array | Why a person should look: each entry has a `code` and a `message` in the language of the reader | | `risk`, `risk_level` | integer, string | A score computed from the status, the confidence and the flags, and `high`, `medium` or `low`. `sort=risk` orders by it | | `resolved` | boolean | Taken out of the review queue | | `processing_ms` | integer | How long the classification took | | `decision`, `decision_label` | string or null | The reviewer's decision: `approved`, `overridden` or `rejected` | | `final_code`, `decided_at` | string or null | The code the decision settled on, and when | | `created_by`, `created_by_name` | string or null | The account that created it | | `sku_id` | integer or null | The SKU of the catalog it belongs to | | `language` | string or null | The answer language ([Languages](/languages.md)) | | `comment_count` | integer or null | Comments on the lookup | Detail fields: | Field | Type | Meaning | | --- | --- | --- | | `input` | object | The fields that were sent: `description`, `material`, `use`, `composition`, `origin`, `notes`, `market` | | `origin_label` | string or null | The country of origin, named in the language of the reader | | `normalized` | object | The description as it was read: the cleaned text, its language, the category, materials and other attributes, and what is missing | | `result` | object | The full answer, see below. Empty until the lookup is finished | | `missing_info` | array | What the description leaves open, in the answer language | | `error` | string or null | The message of a lookup that ended in `error` | | `final_description`, `review_note`, `decided_by_name` | string or null | The reviewer's decision in words | | `events` | array | The history: created, rerun, decisions, comments, each with who, when and a note | | `progress` | object or null | The current stage while `processing`, otherwise null | The parts of `result` an integration reads most: | Field | Meaning | | --- | --- | | `recommended` | The proposed line: `code`, `description`, `full_description`, `duty_rate`, `reasoning`, `citations`, a `duty` estimate basis | | `alternatives` | The other candidates, with the same fields as `recommended`, among them `verdict` and `exclusion_reason` | | `summary`, `deciding_factors`, `uncertainties` | The reasoning in words, in the answer language | | `questions` | Questions that would decide between candidates, with their options. Answer them with a rerun | | `flags` | The same entries as `review_flags` | | `sources` | What the answer cites: each with a `key` (as used in `citations`), `url`, `title` and `snippet` | | `checks` | The automatic checks that ran on the answer, each `ok` or not | | `legal` | The legal findings, in the answer language | | `dataset` | The tariff schedule and version used | | `retrieval` | How the candidate lines were found: `mode` is `hybrid` (text and vector search) or `lexical` (text search alone), with a `reason` when the search was narrower than usual, and how many `lines` of the schedule had a vector (`embedded`) | | `disclaimer` | The statement that the result is a suggestion, in the language of the reader | `result` has more fields than these (scores, timings, ranking detail); see the example answer. ## Classify a product `POST /api/classify` **Changes data in your account when executed.** Creates a lookup with the status `queued` and classifies it in the background. Read `GET /api/lookups/{lookup_id}` until the status is no longer `queued` or `processing`. Spends credits. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string | Yes | Product description. Up to 2000 characters | | `material` | string | No | Up to 500 characters | | `use` | string | No | Up to 500 characters | | `composition` | string | No | Up to 500 characters | | `origin` | string | No | Up to 100 characters | | `notes` | string | No | Other additional information. Up to 1000 characters | | `market` | string | No | Import market: US, EU or VN. Up to 8 characters | | `tariff_version` | string or null | No | Up to 64 characters | | `sku_id` | integer or null | No | Link the result to a SKU in the catalog | | `language` | string or null | No | Language the answer is written in: the reasons and explanations, the questions about missing information with their options, the legal findings and the warnings. A tag with a region is read by its primary subtag (`en-US` is `en`, `ko-KR` is `ko`), in any case. Without it: the language of the request (Accept-Language). Review flags, status labels and the other responses are not affected: they follow `Accept-Language`. Up to 35 characters | **Returns** `202` with `LookupDetail`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/classify" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "description": "Men'\''s T-shirt, knitted, 100% cotton, short sleeves, crew neck", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "notes": "", "market": "US", "language": "en" }' ``` **Example response** `202` ```json { "id": "f4a2251d5dc941ec99f7d459e05082a5", "created_at": "2026-10-03T19:48:02.932457", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": null, "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "processing", "status_label": "Processing", "recommended_code": null, "recommended_description": null, "hs6": null, "confidence": 0, "review_flags": [], "risk": 0, "risk_level": "low", "resolved": false, "processing_ms": 0, "decision": null, "decision_label": null, "final_code": null, "decided_at": null, "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": "Administrator", "sku_id": null, "language": "en", "comment_count": 0, "input": { "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "market": "US" }, "origin_label": "Vietnam (VN)", "normalized": {}, "result": {}, "missing_info": [], "error": null, "final_description": null, "review_note": null, "decided_by_name": null, "events": [], "progress": null } ``` Try it: https://docs.htspilot.com/explorer/#/Lookups/classifyProduct Only `description` is required. `origin` takes a country code from `GET /api/countries`. Without `market`, the default market of the service is used; without `tariff_version`, the active version of that market. ## Get a lookup `GET /api/lookups/{lookup_id}` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `lookup_id` | string | - | **Returns** `200` with `LookupDetail`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/lookups/f4a2251d5dc941ec99f7d459e05082a5" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "f4a2251d5dc941ec99f7d459e05082a5", "created_at": "2026-10-03T19:48:02.932457", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": null, "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "proposed", "status_label": "Proposed", "recommended_code": "6109.10.00.14", "recommended_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "hs6": "610910", "confidence": 0.75, "review_flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "risk": 18, "risk_level": "low", "resolved": true, "processing_ms": 55, "decision": null, "decision_label": null, "final_code": null, "decided_at": null, "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": "Administrator", "sku_id": null, "language": "en", "comment_count": 0, "input": { "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "market": "US" }, "origin_label": "Vietnam (VN)", "normalized": { "raw_description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "clean_description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "search_text": "men's t-shirt, knitted, 100% cotton, short sleeves, crew neck cotton 100% cotton apparel t-shirts", "language": "en", "category": "apparel", "materials": [ { "name": "cotton", "group": "textile", "percent": 100 } ], "construction": "knitted", "gender": "men", "state": null, "electrical": false, "extra": { "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN" }, "missing": [], "flags": [], "product_type": null, "function": null, "search_terms": [], "extraction": { "method": "rules" }, "hs_headings": [], "abbreviations": {}, "translation": null, "material_groups": [ "textile" ] }, "result": { "status": "proposed", "status_label": "Proposed", "lang": "en", "recommended": { "code": "6109.10.00.14", "code_digits": "6109100014", "hs6": "610910", "hs6_display": "6109.10", "national_extension": "0014", "description": "Other T-shirts", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "duty_rate": "16.5%", "retrieval_score": 0.9223, "scores": { "bm25": 11.6074, "vector": 0.4017, "rrf": 0.03226, "overlap": 0.5, "leaf_overlap": 1, "attributes": 0.8, "tree": 1, "cross": 0, "final": 0.9223, "legal": 0 }, "verdict": "recommended", "reasoning": "Tariff line: “Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "", "citations": [ "W1", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": true, "rate_text": "16.5%", "rate_basis": "general", "effective_pct": 16.5, "min_pct": null, "max_pct": 16.5, "additional_pct": 0, "additional": [], "may_apply": [], "specific": false, "currency": "USD" } }, "tentative": false, "alternatives": [ { "code": "6109.10.00.12", "code_digits": "6109100012", "hs6": "610910", "hs6_display": "6109.10", "national_extension": "0012", "description": "T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "duty_rate": "16.5%", "retrieval_score": 0.8596, "scores": { "bm25": 22.6626, "vector": 0.4315, "rrf": 0.03279, "overlap": 0.9, "leaf_overlap": 0.357, "attributes": 0.8, "tree": 1, "cross": 0, "final": 0.8596, "legal": 0 }, "verdict": "possible", "reasoning": "Tariff line: “nglets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "Same HS6 subheading, but the national line matches the details (user/style) less well than the top candidate.", "citations": [ "W2", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": true, "rate_text": "16.5%", "rate_basis": "general", "effective_pct": 16.5, "min_pct": null, "max_pct": 16.5, "additional_pct": 0, "additional": [], "may_apply": [], "specific": false, "currency": "USD" } }, { "code": "6110.20.20.20", "code_digits": "6110202020", "hs6": "611020", "hs6_display": "6110.20", "national_extension": "2020", "description": "Men's or boys' sweatshirts of cotton", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted > Of cotton > Men's or boys' sweatshirts of cotton", "duty_rate": "", "retrieval_score": 0.8095, "scores": { "bm25": 8.2297, "vector": 0.3145, "rrf": 0.02986, "overlap": 0.4, "leaf_overlap": 0.5, "attributes": 0.88, "tree": 0.9171, "cross": 0, "final": 0.8095, "legal": 0 }, "verdict": "possible", "reasoning": "Tariff line: “ 61: Articles of apparel and clothing accessories, knitted or crocheted > Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted > Of cotton > Men's or boys' sweatshirts of cotton”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "Different HS subheading 611020; matches the description less well than the top candidate.", "citations": [ "W3", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": false, "rate_text": "" } } ], "confidence": 0.75, "flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "uncertainties": [], "missing_info": [], "questions": [], "summary": "Proposed 6109.10.00.14 based on how well the description and attributes match.", "deciding_factors": [ "6109.10.00.14: heading usually used for 'apparel' products", "6109.10.00.14: matches main material: cotton" ], "duty": { "origin": "VN", "country": "VN", "codes_compared": 2, "min_pct": 16.5, "max_pct": 16.5, "spread_pp": 0 }, "raw_confidence": 0.75, "sources": [ { "key": "W1", "url": "https://hts.usitc.gov/", "title": "Loaded tariff data – US 2026-SAMPLE – 6109.10.00.14", "publisher": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "snippet": "6109.10.00.14: Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "accessed_at": "2026-10-03T19:48:02.982666+00:00", "is_official": false, "is_demo": true, "provider": "tariff_data", "related_codes": [ "6109100014" ] }, { "key": "W2", "url": "https://hts.usitc.gov/", "title": "Loaded tariff data – US 2026-SAMPLE – 6109.10.00.12", "publisher": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "snippet": "6109.10.00.12: Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "accessed_at": "2026-10-03T19:48:02.982666+00:00", "is_official": false, "is_demo": true, "provider": "tariff_data", "related_codes": [ "6109100012" ] } ], "precedents": [], "legal": { "guidance": [ { "rule": "gri6-same-level", "effect": "guidance", "ref": "GRI 6", "note_ref": "GRI6", "params": { "dominant": "cotton", "percent": "100" }, "message": "Subheadings are compared only with subheadings at the same level, by their terms and the related notes." } ], "excluded": [ { "code": "6205202046", "code_display": "6205.20.20.46", "findings": [ { "rule": "ch62-not-knitted", "effect": "exclude", "ref": "HS Chapter 62, Note 1", "note_ref": "62", "params": { "dominant": "cotton", "percent": "100" }, "message": "Chapter 62 excludes knitted or crocheted articles (other than those of heading 62.12); the product is knitted." } ] }, { "code": "6205202016", "code_display": "6205.20.20.16", "findings": [ { "rule": "ch62-not-knitted", "effect": "exclude", "ref": "HS Chapter 62, Note 1", "note_ref": "62", "params": { "dominant": "cotton", "percent": "100" }, "message": "Chapter 62 excludes knitted or crocheted articles (other than those of heading 62.12); the product is knitted." } ] } ], "rules_fired": [ "gender_line", "ch62-not-knitted" ] }, "pipeline": { "understanding": { "method": "rules" }, "translation": null, "sufficient_info": true, "early_exit": false, "tree": { "chapters": [ { "code": "61", "title": "Articles of apparel and clothing accessories, knitted or crocheted", "score": 1.15 }, { "code": "62", "title": "Articles of apparel and clothing accessories, not knitted or crocheted", "score": 1.087 } ], "headings": [ { "code": "6109", "title": "T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "score": 1.15 }, { "code": "6205", "title": "Men's or boys' shirts (not knitted or crocheted)", "score": 1.087 } ], "hs6": 26, "leaves": 43, "widened": false }, "retrievers": { "bm25": 24, "vector": 40, "cross": 0, "precedents": 0 }, "reranker": "default", "calibrated": false, "pool": 32, "legal_excluded": 5, "shortlist": null, "top_k": 16, "top_k_codes": [ "6109100014", "6109100012" ], "line_select": null, "national_line": null }, "ranking": { "model": "default", "candidates": [ { "code": "6109100014", "hs6": "610910", "features": { "rrf": 0.9839, "bm25": 0.5122, "vector": 0.4017, "overlap": 0.5, "leaf_overlap": 1, "attributes": 0.8, "tree": 1, "cross": 0, "legal": 0, "xrerank": 0 } }, { "code": "6109100012", "hs6": "610910", "features": { "rrf": 1, "bm25": 1, "vector": 0.4315, "overlap": 0.9, "leaf_overlap": 0.3571, "attributes": 0.8, "tree": 1, "cross": 0, "legal": 0, "xrerank": 0 } } ] }, "notes": [ { "key": "N1", "doc_type": "chapter_note", "ref_code": "61", "title": "Chapter 61 – Scope", "content": "Chapter 61 applies only to made up knitted or crocheted articles. Heading 6109 covers T-shirts, singlets and tank tops. Articles of apparel that are not knitted or crocheted fall in Chapter 62. [Illustrative summary for demo mode – check against the official text before use.]", "source_url": "https://hts.usitc.gov/" }, { "key": "N2", "doc_type": "gri", "ref_code": "GRI1", "title": "GRI 1 – Terms of headings and legal notes", "content": "Classification is determined according to the terms of the headings and any relative Section or Chapter Notes; titles of sections and chapters are for ease of reference only. [Illustrative summary for demo mode – check against the official text before use.]", "source_url": "https://hts.usitc.gov/" } ], "retrieval": { "mode": "hybrid", "reason": null, "embedded": 71, "lines": 71 }, "checks": [ { "check": "evidence_matches_source", "ok": true, "detail": "W1", "label": "Evidence matches source" }, { "check": "evidence_matches_source", "ok": true, "detail": "W2", "label": "Evidence matches source" } ], "warnings": [], "timings_ms": { "understand": 13, "retrieve": 17, "legal": 15, "evidence": 0, "llm": 0, "validate": 3, "duty": 2 }, "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "source_url": "https://hts.usitc.gov/", "loaded_at": "2026-10-03T19:48:02.737682", "national_digits": 10, "effective_date": "2026-01-01" }, "disclaimer": "This result is an automated suggestion for reference only, not an official classification decision. The declarant is responsible for the declared code; for complex cases, request an advance ruling from the customs authority." }, "missing_info": [], "error": null, "final_description": null, "review_note": null, "decided_by_name": null, "events": [ { "id": 1, "action": "created", "user_name": "Administrator", "user_role": "admin", "from_code": null, "to_code": "6109.10.00.14", "note": "AI: Proposed", "dataset_version": "2026-SAMPLE", "created_at": "2026-10-03T19:48:02.993175" }, { "id": 2, "action": "rerun", "user_name": "Administrator (API: Documentation examples)", "user_role": "admin", "from_code": null, "to_code": null, "note": "Rerun with additional details → 2655d2f9c3b047c6906b25d45a24c522", "dataset_version": "2026-SAMPLE", "created_at": "2026-10-03T19:48:03.200629" } ], "progress": null } ``` Try it: https://docs.htspilot.com/explorer/#/Lookups/getLookup ## List lookups `GET /api/lookups` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string or null | No | Up to 200 characters | | `status` | string[] or null | No | - | | `market` | string or null | No | - | | `kind` | string or null | No | - | | `decision` | string or null | No | - | | `page` | integer | No | Minimum 1, default `1` | | `page_size` | integer | No | Minimum 1, maximum 200, default `20` | | `sort` | one of: newest, oldest, confidence, risk | No | Newest (default), oldest, confidence (least certain first), or risk (highest first, then oldest first). With confidence, queued, processing and failed lookups (confidence 0.0) come first. Default `"newest"` | | `date_from` | string or null | No | Created on or after this day (UTC) | | `date_to` | string or null | No | Created on or before this day (UTC) | | `created_by` | string or null | No | User id, or 'me'. Up to 32 characters | | `confidence_min` | number or null | No | Minimum 0, maximum 1 | | `confidence_max` | number or null | No | Minimum 0, maximum 1 | | `flag` | string or null | No | Review flag code, e.g. low_confidence. Pattern `^[a-z0-9_]{1,40}$` | **Returns** `200` with `Page`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/lookups?page=1&page_size=20" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "items": [ { "id": "f4a2251d5dc941ec99f7d459e05082a5", "created_at": "2026-10-03T19:48:02.932457", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": null, "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "proposed", "status_label": "Proposed", "recommended_code": "6109.10.00.14", "recommended_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "hs6": "610910", "confidence": 0.75, "review_flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "risk": 18, "risk_level": "low", "resolved": false, "processing_ms": 55, "decision": null, "decision_label": null, "final_code": null, "decided_at": null, "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": "Administrator", "sku_id": null, "language": "en", "comment_count": 0 } ], "total": 1, "page": 1, "page_size": 20 } ``` Try it: https://docs.htspilot.com/explorer/#/Lookups/listLookups A key with the `entry` role lists the lookups of its own account. `status` may be repeated to ask for several states. See [Pagination](/pagination.md). ## Rerun a lookup with more information `POST /api/lookups/{lookup_id}/rerun` **Changes data in your account when executed.** Runs the lookup again with the fields you send added to, or replacing, the ones it had. The answer is a new lookup; the old one is marked resolved. It is written in the language of the lookup it continues, unless the request names another `language`. Spends credits. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `lookup_id` | string | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string or null | No | Up to 2000 characters | | `material` | string or null | No | Up to 500 characters | | `use` | string or null | No | Up to 500 characters | | `composition` | string or null | No | Up to 500 characters | | `origin` | string or null | No | Up to 100 characters | | `notes` | string or null | No | Up to 1000 characters | | `market` | string or null | No | Up to 8 characters | | `tariff_version` | string or null | No | Up to 64 characters | | `language` | string or null | No | Language the answer is written in: the reasons and explanations, the questions about missing information with their options, the legal findings and the warnings. A tag with a region is read by its primary subtag (`en-US` is `en`, `ko-KR` is `ko`), in any case. Without it: the language of the lookup that is run again. Review flags, status labels and the other responses are not affected: they follow `Accept-Language`. Up to 35 characters | **Returns** `202` with `LookupDetail`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/lookups/f4a2251d5dc941ec99f7d459e05082a5/rerun" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "composition": "60% cotton, 40% polyester", "notes": "Crew neck, no pockets" }' ``` **Example response** `202` ```json { "id": "2655d2f9c3b047c6906b25d45a24c522", "created_at": "2026-10-03T19:48:03.200629", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": "f4a2251d5dc941ec99f7d459e05082a5", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "queued", "status_label": "New – queued", "recommended_code": null, "recommended_description": null, "hs6": null, "confidence": 0, "review_flags": [], "risk": 0, "risk_level": "low", "resolved": false, "processing_ms": 0, "decision": null, "decision_label": null, "final_code": null, "decided_at": null, "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": "Administrator", "sku_id": null, "language": "en", "comment_count": 0, "input": { "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "material": "cotton", "use": "apparel", "composition": "60% cotton, 40% polyester", "origin": "VN", "notes": "Crew neck, no pockets", "market": "US", "tariff_version": "2026-SAMPLE" }, "origin_label": "Vietnam (VN)", "normalized": {}, "result": {}, "missing_info": [], "error": null, "final_description": null, "review_note": null, "decided_by_name": null, "events": [], "progress": null } ``` Try it: https://docs.htspilot.com/explorer/#/Lookups/rerunLookup Send only the fields that change. The answer is a new lookup with `parent_lookup_id` set; the old one is marked `resolved`. This is how a `needs_info` lookup is continued: add what `missing_info` and `result.questions` ask for. ## Count lookups `GET /api/stats` **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/stats" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "by_status": { "proposed": 1 }, "total": 1, "pending_review": 0, "open_alerts": 0 } ``` Try it: https://docs.htspilot.com/explorer/#/Lookups/getLookupCounts # Review > The queue of lookups that need a person, and the reviewer's decision on a lookup - approve, override, reject, reopen or comment. The review queue holds the lookups that need a person: those that ended in `needs_review`, `needs_info` or `error` and have no decision yet. A reviewer decides on a lookup, and the decision is recorded next to the proposal, with who decided and why. The objects are lookups ([Lookups](/lookups.md)). Reading the queue takes the `entry` role, which sees its own lookups. Deciding and resolving need the `reviewer` or the `admin` role. ## The decision | `action` | Needs | Effect | | --- | --- | --- | | `approve` | - | Accepts the recommended code. `decision` becomes `approved`, `final_code` the recommended code | | `override` | `code` and `note` | Settles on another code. `code` must be a declarable line of the lookup's tariff schedule. `decision` becomes `overridden` | | `reject` | `note` | No code is accepted. `decision` becomes `rejected` | | `reopen` | - | Removes the decision. Answers `409` when there is none | | `comment` | `note` | Adds a note to the history without deciding | Every action is added to the `events` of the lookup. `approve`, `override` and `reject` send the webhook event `lookup.decided` ([Webhooks](/webhooks.md)); `reopen` and `comment` send none. A lookup that already has a decision answers `409` to another one: reopen it first. An `override` with the recommended code itself is recorded as `approved`. When the lookup belongs to a SKU, the decided code becomes the SKU's `current_code`. Comments are limited per minute ([Rate limits and quotas](/rate-limits.md)). ## List the review queue `GET /api/review` Lookups that need information, need review or failed, and have no decision yet. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string or null | No | Up to 200 characters | | `status` | string[] or null | No | - | | `market` | string or null | No | - | | `include_resolved` | boolean | No | Default `false` | | `page` | integer | No | Minimum 1, default `1` | | `page_size` | integer | No | Minimum 1, maximum 200, default `20` | | `sort` | one of: newest, oldest, confidence, risk | No | Newest (default), oldest, confidence (least certain first), or risk (highest first, then oldest first). With confidence, failed lookups (confidence 0.0) come first. Default `"newest"` | | `date_from` | string or null | No | Created on or after this day (UTC) | | `date_to` | string or null | No | Created on or before this day (UTC) | | `created_by` | string or null | No | User id, or 'me'. Up to 32 characters | | `confidence_min` | number or null | No | Minimum 0, maximum 1 | | `confidence_max` | number or null | No | Minimum 0, maximum 1 | | `flag` | string or null | No | Review flag code, e.g. low_confidence. Pattern `^[a-z0-9_]{1,40}$` | **Returns** `200` with `Page`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/review?page=1&page_size=20&sort=risk" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "items": [], "total": 0, "page": 1, "page_size": 20 } ``` Try it: https://docs.htspilot.com/explorer/#/Review/listReviewQueue The filters are those of the lookup list, without `kind` and `decision`. `sort=risk` puts the highest risk first. `include_resolved=true` also returns the lookups that were taken out of the queue. ## Count the review queue `GET /api/review/counts` The number of queue items per status, for the same filters as `GET /api/review`. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string or null | No | Up to 200 characters | | `status` | string[] or null | No | - | | `market` | string or null | No | - | | `include_resolved` | boolean | No | Default `false` | | `date_from` | string or null | No | Created on or after this day (UTC) | | `date_to` | string or null | No | Created on or before this day (UTC) | | `created_by` | string or null | No | User id, or 'me'. Up to 32 characters | | `confidence_min` | number or null | No | Minimum 0, maximum 1 | | `confidence_max` | number or null | No | Minimum 0, maximum 1 | | `flag` | string or null | No | Review flag code, e.g. low_confidence. Pattern `^[a-z0-9_]{1,40}$` | **Returns** `200` with `ReviewCounts`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/review/counts" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "total": 0, "counts": { "needs_info": 0, "needs_review": 0, "error": 0 } } ``` Try it: https://docs.htspilot.com/explorer/#/Review/countReviewQueue ## Decide on a lookup `POST /api/lookups/{lookup_id}/decision` **Changes data in your account when executed.** `approve` accepts the recommended code. `override` needs `code`, a declarable line of the lookup's tariff schedule, and a `note` with the reason. `reject` needs a `note`. `reopen` removes a decision. `comment` adds a note without deciding. Needs the reviewer or the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `lookup_id` | string | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `action` | one of: approve, override, reject, reopen, comment | Yes | - | | `code` | string or null | No | Final code when action=override. Up to 24 characters | | `note` | string | No | Up to 2000 characters | **Returns** `200` with `LookupDetail`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/lookups/2655d2f9c3b047c6906b25d45a24c522/decision" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "action": "approve", "note": "Checked against the heading notes" }' ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "2655d2f9c3b047c6906b25d45a24c522", "created_at": "2026-10-03T19:48:03.200629", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": "f4a2251d5dc941ec99f7d459e05082a5", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "proposed", "status_label": "Proposed", "recommended_code": "6109.10.00.14", "recommended_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "hs6": "610910", "confidence": 0.74, "review_flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "risk": 18, "risk_level": "low", "resolved": true, "processing_ms": 37, "decision": "approved", "decision_label": "AI code approved", "final_code": "6109.10.00.14", "decided_at": "2026-10-03T19:48:03.423841Z", "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": "Administrator", "sku_id": null, "language": "en", "comment_count": 1, "input": { "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "material": "cotton", "use": "apparel", "composition": "60% cotton, 40% polyester", "origin": "VN", "notes": "Crew neck, no pockets", "market": "US", "tariff_version": "2026-SAMPLE" }, "origin_label": "Vietnam (VN)", "normalized": { "raw_description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "clean_description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "search_text": "men's t-shirt, knitted, 100% cotton, short sleeves, crew neck cotton 60% cotton, 40% polyester apparel crew neck, no pockets t-shirts", "language": "en", "category": "apparel", "materials": [ { "name": "cotton", "group": "textile", "percent": 60 }, { "name": "polyester", "group": "textile_manmade", "percent": 40 } ], "construction": "knitted", "gender": "men", "state": null, "electrical": false, "extra": { "material": "cotton", "use": "apparel", "composition": "60% cotton, 40% polyester", "origin": "VN", "notes": "Crew neck, no pockets" }, "missing": [], "flags": [], "product_type": null, "function": null, "search_terms": [], "extraction": { "method": "rules" }, "hs_headings": [], "abbreviations": {}, "translation": null, "material_groups": [ "textile", "textile_manmade" ] }, "result": { "status": "proposed", "status_label": "Proposed", "lang": "en", "recommended": { "code": "6109.10.00.14", "code_digits": "6109100014", "hs6": "610910", "hs6_display": "6109.10", "national_extension": "0014", "description": "Other T-shirts", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "duty_rate": "16.5%", "retrieval_score": 0.916, "scores": { "bm25": 11.6074, "vector": 0.4186, "rrf": 0.03226, "overlap": 0.333, "leaf_overlap": 1, "attributes": 0.8, "tree": 1, "cross": 0, "final": 0.916, "legal": 0 }, "verdict": "recommended", "reasoning": "Tariff line: “Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "", "citations": [ "W1", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": true, "rate_text": "16.5%", "rate_basis": "general", "effective_pct": 16.5, "min_pct": null, "max_pct": 16.5, "additional_pct": 0, "additional": [], "may_apply": [], "specific": false, "currency": "USD" } }, "tentative": false, "alternatives": [ { "code": "6109.10.00.12", "code_digits": "6109100012", "hs6": "610910", "hs6_display": "6109.10", "national_extension": "0012", "description": "T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "duty_rate": "16.5%", "retrieval_score": 0.8672, "scores": { "bm25": 26.7971, "vector": 0.4887, "rrf": 0.03279, "overlap": 0.667, "leaf_overlap": 0.429, "attributes": 0.8, "tree": 1, "cross": 0, "final": 0.8672, "legal": 0 }, "verdict": "possible", "reasoning": "Tariff line: “nglets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "Same HS6 subheading, but the national line matches the details (user/style) less well than the top candidate.", "citations": [ "W2", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": true, "rate_text": "16.5%", "rate_basis": "general", "effective_pct": 16.5, "min_pct": null, "max_pct": 16.5, "additional_pct": 0, "additional": [], "may_apply": [], "specific": false, "currency": "USD" } }, { "code": "6110.20.20.20", "code_digits": "6110202020", "hs6": "611020", "hs6_display": "6110.20", "national_extension": "2020", "description": "Men's or boys' sweatshirts of cotton", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted > Of cotton > Men's or boys' sweatshirts of cotton", "duty_rate": "", "retrieval_score": 0.8024, "scores": { "bm25": 8.2297, "vector": 0.3391, "rrf": 0.02963, "overlap": 0.267, "leaf_overlap": 0.5, "attributes": 0.88, "tree": 0.9154, "cross": 0, "final": 0.8024, "legal": 0 }, "verdict": "possible", "reasoning": "Tariff line: “ 61: Articles of apparel and clothing accessories, knitted or crocheted > Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted > Of cotton > Men's or boys' sweatshirts of cotton”. Matches: heading usually used for 'apparel' products; matches main material: cotton; matches construction: knitted; matches intended user: men.", "exclusion_reason": "Different HS subheading 611020; matches the description less well than the top candidate.", "citations": [ "W3", "N1" ], "signals": [ "heading usually used for 'apparel' products", "matches main material: cotton" ], "legal": { "status": "ok", "penalty": 0, "findings": [] }, "precedents": [], "duty": { "available": false, "rate_text": "" } } ], "confidence": 0.74, "flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "uncertainties": [], "missing_info": [], "questions": [], "summary": "Proposed 6109.10.00.14 based on how well the description and attributes match.", "deciding_factors": [ "6109.10.00.14: heading usually used for 'apparel' products", "6109.10.00.14: matches main material: cotton" ], "duty": { "origin": "VN", "country": "VN", "codes_compared": 2, "min_pct": 16.5, "max_pct": 16.5, "spread_pp": 0 }, "raw_confidence": 0.74, "sources": [ { "key": "W1", "url": "https://hts.usitc.gov/", "title": "Loaded tariff data – US 2026-SAMPLE – 6109.10.00.14", "publisher": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "snippet": "6109.10.00.14: Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "accessed_at": "2026-10-03T19:48:03.234689+00:00", "is_official": false, "is_demo": true, "provider": "tariff_data", "related_codes": [ "6109100014" ] }, { "key": "W2", "url": "https://hts.usitc.gov/", "title": "Loaded tariff data – US 2026-SAMPLE – 6109.10.00.12", "publisher": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "snippet": "6109.10.00.12: Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "accessed_at": "2026-10-03T19:48:03.234689+00:00", "is_official": false, "is_demo": true, "provider": "tariff_data", "related_codes": [ "6109100012" ] } ], "precedents": [], "legal": { "guidance": [ { "rule": "sxi-note2-predominant-fibre", "effect": "guidance", "ref": "HS Section XI, Note 2(A)", "note_ref": "", "params": { "dominant": "cotton", "percent": "60" }, "message": "Goods of two or more textile materials are classified as if wholly of the fibre that predominates by weight: cotton (60%)." }, { "rule": "gri6-same-level", "effect": "guidance", "ref": "GRI 6", "note_ref": "GRI6", "params": { "dominant": "cotton", "percent": "60" }, "message": "Subheadings are compared only with subheadings at the same level, by their terms and the related notes." } ], "excluded": [ { "code": "6205202046", "code_display": "6205.20.20.46", "findings": [ { "rule": "ch62-not-knitted", "effect": "exclude", "ref": "HS Chapter 62, Note 1", "note_ref": "62", "params": { "dominant": "cotton", "percent": "60" }, "message": "Chapter 62 excludes knitted or crocheted articles (other than those of heading 62.12); the product is knitted." } ] }, { "code": "6205202016", "code_display": "6205.20.20.16", "findings": [ { "rule": "ch62-not-knitted", "effect": "exclude", "ref": "HS Chapter 62, Note 1", "note_ref": "62", "params": { "dominant": "cotton", "percent": "60" }, "message": "Chapter 62 excludes knitted or crocheted articles (other than those of heading 62.12); the product is knitted." } ] } ], "rules_fired": [ "gender_line", "ch62-not-knitted" ] }, "pipeline": { "understanding": { "method": "rules" }, "translation": null, "sufficient_info": true, "early_exit": false, "tree": { "chapters": [ { "code": "61", "title": "Articles of apparel and clothing accessories, knitted or crocheted", "score": 1.15 }, { "code": "62", "title": "Articles of apparel and clothing accessories, not knitted or crocheted", "score": 1.087 } ], "headings": [ { "code": "6109", "title": "T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "score": 1.15 }, { "code": "6205", "title": "Men's or boys' shirts (not knitted or crocheted)", "score": 1.087 } ], "hs6": 19, "leaves": 36, "widened": false }, "retrievers": { "bm25": 25, "vector": 36, "cross": 0, "precedents": 0 }, "reranker": "default", "calibrated": false, "pool": 31, "legal_excluded": 5, "shortlist": null, "top_k": 16, "top_k_codes": [ "6109100014", "6109100012" ], "line_select": null, "national_line": null }, "ranking": { "model": "default", "candidates": [ { "code": "6109100014", "hs6": "610910", "features": { "rrf": 0.9839, "bm25": 0.4332, "vector": 0.4186, "overlap": 0.3333, "leaf_overlap": 1, "attributes": 0.8, "tree": 1, "cross": 0, "legal": 0, "xrerank": 0 } }, { "code": "6109100012", "hs6": "610910", "features": { "rrf": 1, "bm25": 1, "vector": 0.4887, "overlap": 0.6667, "leaf_overlap": 0.4286, "attributes": 0.8, "tree": 1, "cross": 0, "legal": 0, "xrerank": 0 } } ] }, "notes": [ { "key": "N1", "doc_type": "chapter_note", "ref_code": "61", "title": "Chapter 61 – Scope", "content": "Chapter 61 applies only to made up knitted or crocheted articles. Heading 6109 covers T-shirts, singlets and tank tops. Articles of apparel that are not knitted or crocheted fall in Chapter 62. [Illustrative summary for demo mode – check against the official text before use.]", "source_url": "https://hts.usitc.gov/" }, { "key": "N2", "doc_type": "gri", "ref_code": "GRI1", "title": "GRI 1 – Terms of headings and legal notes", "content": "Classification is determined according to the terms of the headings and any relative Section or Chapter Notes; titles of sections and chapters are for ease of reference only. [Illustrative summary for demo mode – check against the official text before use.]", "source_url": "https://hts.usitc.gov/" } ], "retrieval": { "mode": "hybrid", "reason": null, "embedded": 71, "lines": 71 }, "checks": [ { "check": "evidence_matches_source", "ok": true, "detail": "W1", "label": "Evidence matches source" }, { "check": "evidence_matches_source", "ok": true, "detail": "W2", "label": "Evidence matches source" } ], "warnings": [], "timings_ms": { "understand": 2, "retrieve": 13, "legal": 13, "evidence": 0, "llm": 0, "validate": 3, "duty": 1 }, "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "source_url": "https://hts.usitc.gov/", "loaded_at": "2026-10-03T19:48:02.737682", "national_digits": 10, "effective_date": "2026-01-01" }, "disclaimer": "This result is an automated suggestion for reference only, not an official classification decision. The declarant is responsible for the declared code; for complex cases, request an advance ruling from the customs authority." }, "missing_info": [], "error": null, "final_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "review_note": "Checked against the heading notes", "decided_by_name": "Administrator", "events": [ { "id": 3, "action": "created", "user_name": "Administrator", "user_role": "admin", "from_code": null, "to_code": "6109.10.00.14", "note": "Rerun of f4a2251d5dc941ec99f7d459e05082a5; AI: Proposed", "dataset_version": "2026-SAMPLE", "created_at": "2026-10-03T19:48:03.242335" }, { "id": 4, "action": "approve", "user_name": "Administrator (API: Documentation examples)", "user_role": "admin", "from_code": "6109.10.00.14", "to_code": "6109.10.00.14", "note": "Checked against the heading notes", "dataset_version": "2026-SAMPLE", "created_at": "2026-10-03T19:48:03.423841" } ], "progress": null } ``` Try it: https://docs.htspilot.com/explorer/#/Review/decideLookup ## Resolve a lookup without a decision `POST /api/lookups/{lookup_id}/resolve` **Changes data in your account when executed.** Needs the reviewer or the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `lookup_id` | string | - | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `resolved` | boolean | No | Default `true` | **Returns** `200` with `LookupSummary`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/lookups/2655d2f9c3b047c6906b25d45a24c522/resolve?resolved=true" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "id": "2655d2f9c3b047c6906b25d45a24c522", "created_at": "2026-10-03T19:48:03.200629", "kind": "single", "batch_id": null, "row_index": null, "parent_lookup_id": "f4a2251d5dc941ec99f7d459e05082a5", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves, crew neck", "market": "US", "dataset_version": "2026-SAMPLE", "status": "proposed", "status_label": "Proposed", "recommended_code": "6109.10.00.14", "recommended_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "hs6": "610910", "confidence": 0.74, "review_flags": [ { "code": "no_official_evidence", "message": "No official source confirms the proposed code" }, { "code": "demo_data", "message": "Tariff data is incomplete" } ], "risk": 18, "risk_level": "low", "resolved": true, "processing_ms": 37, "decision": "approved", "decision_label": "AI code approved", "final_code": "6109.10.00.14", "decided_at": "2026-10-03T19:48:03.423841", "created_by": "a7ba4a2440c7422fb695a1fd80d65ce5", "created_by_name": null, "sku_id": null, "language": "en", "comment_count": null } ``` Try it: https://docs.htspilot.com/explorer/#/Review/resolveLookup `resolved=false` puts the lookup back in the queue. # Batches > Classify a file of products - upload an .xlsx or .csv file, choose the columns, start it, follow its progress and download the results. A batch classifies every row of a spreadsheet. The flow has five steps: 1. Upload the file (`POST /api/batches`). The answer lists the columns found, a suggested mapping and a preview. Nothing is classified yet. 2. Optionally check the rows with your column mapping (`POST /api/batches/{batch_id}/validate`). 3. Start it (`POST /api/batches/{batch_id}/start`) with the column mapping. The start can answer `402` when the account cannot be charged for the rows. 4. Poll `GET /api/batches/{batch_id}/progress` until the status is `completed` or `completed_with_errors`, or wait for the `batch.completed` webhook ([Asynchronous work](/asynchronous-work.md)). 5. Read the rows (`GET /api/batches/{batch_id}/rows`) or download the results as Excel (`GET /api/batches/{batch_id}/export`). Each row that is classified becomes a lookup of the kind `batch` ([Lookups](/lookups.md)) and can be charged ([Rate limits and quotas](/rate-limits.md#credits)). Rows send no `lookup.completed` event: the batch sends `batch.completed` when it is done. Batches need the plan feature `feature.batch`, and the number of rows is limited by the plan ([Rate limits and quotas](/rate-limits.md)). ## The file An `.xlsx` or `.csv` file whose first row holds the column names. `GET /api/batches/template` gives a file to start from, with the columns `description`, `material`, `use`, `composition`, `origin`, `notes`, `sku` and `hts_code`. Your own column names work too: the column mapping says which column holds which field. Only `description` is required. | Mapping field | Column that holds | | --- | --- | | `description` | The product description. Required | | `material`, `use`, `composition`, `origin`, `notes` | The same fields as on a lookup | | `sku` | Your SKU code. Needed for `save_to_catalog` | | `existing_code` | The code in use today. It is checked against the tariff schedule and compared with the suggestion | ## The batch object | Field | Type | Meaning | | --- | --- | --- | | `id` | string | The id of the batch | | `created_at`, `updated_at` | string | ISO 8601, UTC | | `filename` | string | The name of the uploaded file | | `status` | string | `uploaded`, `queued`, `running`, `completed` or `completed_with_errors` | | `columns` | array | The column names found in the file | | `column_mapping` | object | The mapping the batch was started with | | `market`, `dataset_version` | string | The market and tariff version of the batch | | `validation` | object | The suggested mapping and the result of the row check | | `total_rows`, `processed_rows`, `failed_rows` | integer | Counters | | `counts` | object | Rows per row status | | `error` | string or null | Not set today: a failure belongs to a row, and a batch with failed rows ends as `completed_with_errors` | | `save_to_catalog` | boolean | Whether rows are saved to the SKU catalog | | `preview` | array | The first rows of the file: after an upload, and on `GET /api/batches/{batch_id}` with `preview=true` | ## The row object | Field | Type | Meaning | | --- | --- | --- | | `row_index` | integer | The row number in the file. The first data row is 2 | | `raw` | object | The cells of the row, by column name | | `status` | string | `pending`, `processing`, `done`, `error`, `skipped` or `duplicate` (the same product as an earlier row, named in `duplicate_of`, whose result it reuses) | | `issues` | array | What the row check found | | `duplicate_of` | integer or null | The `row_index` it duplicates | | `lookup_id` | string or null | The lookup of the row | | `attempts` | integer | How often it was tried | | `error` | string or null | Why it failed | | `result_status`, `recommended_code`, `confidence` | - | The outcome of the lookup | | `code_check` | object | The check of the existing code, when a column was mapped to `existing_code` | | `final_code` | string or null | The reviewer's code, once decided | ## Download the template `GET /api/batches/template` An .xlsx file with the columns `description`, `material`, `use`, `composition`, `origin`, `notes`, `sku` and `hts_code`, example rows and a guide sheet. **Returns** `200` with a file (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`). **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches/template" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -o "hts_lookup_template.xlsx" ``` **Example response** `200` ```json { "file": true, "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "content_disposition": "attachment; filename=\"hts_lookup_template.xlsx\"", "bytes": 11735 } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/getBatchTemplate ## Upload a file `POST /api/batches` **Changes data in your account when executed.** Send the file as `multipart/form-data` in the field `file`. The first row holds the column names; `GET /api/batches/template` gives a file to start from. The answer lists the columns found, a suggested mapping and a preview; nothing is classified until the batch is started. **Request body** (`multipart/form-data`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `file` | file | Yes | - | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/batches" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -F "file=@products.csv" ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967Z", "updated_at": "2026-10-03T19:48:03.474092Z", "filename": "products.csv", "status": "uploaded", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "", "dataset_version": null, "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] } }, "total_rows": 3, "processed_rows": 0, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "pending": 3 }, "preview": [ { "row_index": 2, "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "origin": "VN", "sku": "EXAMPLE-TSH-001", "hts_code": "6109.10.00.12" }, { "row_index": 3, "description": "Cotton terry bath towel, 70 x 140 cm", "material": "cotton", "origin": "IN", "sku": "EXAMPLE-TWL-014", "hts_code": "" } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/uploadBatch ```bash curl -X POST https://htspilot.com/api/batches \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -F "file=@products.csv" ``` ## Check the rows `POST /api/batches/{batch_id}/validate` **Changes data in your account when executed.** **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `column_mapping` | ColumnMapping | Yes | - | | `market` | string | No | Up to 8 characters | | `tariff_version` | string or null | No | - | | `save_to_catalog` | boolean | No | Save/update the SKU in the shared catalog (requires a sku column). Default `false` | **`column_mapping`**: ColumnMapping | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string | Yes | - | | `material` | string or null | No | - | | `use` | string or null | No | - | | `composition` | string or null | No | - | | `origin` | string or null | No | - | | `notes` | string or null | No | - | | `sku` | string or null | No | - | | `existing_code` | string or null | No | Column with the HTS code currently in use: it is checked for validity and compared with the suggestion | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/validate" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "save_to_catalog": false }' ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.474092", "filename": "products.csv", "status": "uploaded", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "", "dataset_version": null, "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] } }, "total_rows": 3, "processed_rows": 0, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "pending": 3 }, "preview": [ { "row_index": 2, "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "origin": "VN", "sku": "EXAMPLE-TSH-001", "hts_code": "6109.10.00.12" }, { "row_index": 3, "description": "Cotton terry bath towel, 70 x 140 cm", "material": "cotton", "origin": "IN", "sku": "EXAMPLE-TWL-014", "hts_code": "" } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/validateBatch ## Start a batch `POST /api/batches/{batch_id}/start` **Changes data in your account when executed.** `column_mapping` names the column of the file for each field; only `description` is required. Spends credits for every row that is classified. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `column_mapping` | ColumnMapping | Yes | - | | `market` | string | No | Up to 8 characters | | `tariff_version` | string or null | No | - | | `save_to_catalog` | boolean | No | Save/update the SKU in the shared catalog (requires a sku column). Default `false` | **`column_mapping`**: ColumnMapping | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string | Yes | - | | `material` | string or null | No | - | | `use` | string or null | No | - | | `composition` | string or null | No | - | | `origin` | string or null | No | - | | `notes` | string or null | No | - | | `sku` | string or null | No | - | | `existing_code` | string or null | No | Column with the HTS code currently in use: it is checked for validity and compared with the suggestion | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/start" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "save_to_catalog": false }' ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.486727Z", "filename": "products.csv", "status": "queued", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "dataset_version": "2026-SAMPLE", "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "final": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "lang": "en" }, "total_rows": 3, "processed_rows": 0, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "pending": 3 }, "preview": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/startBatch A batch can be started once, from the status `uploaded`; a second start answers `409`. ## Follow the progress `GET /api/batches/{batch_id}/progress` The status, the counters and `version`, a token that changes whenever the batch or its rows changed. Poll this and read the rows again only when `version` differs from the one last seen. The token is opaque: compare it, do not parse it. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Returns** `200` with `BatchProgressOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/progress" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```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" } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/getBatchProgress ## Get a batch `GET /api/batches/{batch_id}` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `preview` | boolean | No | Default `false` | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.474092", "filename": "products.csv", "status": "uploaded", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "", "dataset_version": null, "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] } }, "total_rows": 3, "processed_rows": 0, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "pending": 3 }, "preview": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/getBatch ## List batches `GET /api/batches` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | No | Minimum 1, maximum 100, default `20` | **Returns** `200` with `BatchOut[]`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json [ { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.747413", "filename": "products.csv", "status": "completed", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "dataset_version": "2026-SAMPLE", "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "final": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "lang": "en" }, "total_rows": 3, "processed_rows": 3, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "done": 3 }, "preview": [] } ] ``` Try it: https://docs.htspilot.com/explorer/#/Batches/listBatches ## List the rows `GET /api/batches/{batch_id}/rows` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string or null | No | - | | `offset` | integer | No | Minimum 0, default `0` | | `limit` | integer | No | Minimum 1, maximum 2000, default `200` | **Returns** `200` with `BatchRowOut[]`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/rows" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json [ { "row_index": 2, "raw": { "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "origin": "VN", "sku": "EXAMPLE-TSH-001", "hts_code": "6109.10.00.12" }, "status": "done", "issues": [], "duplicate_of": null, "lookup_id": "64449f0bcfd94c2dac722d0b40898386", "attempts": 1, "error": null, "result_status": "proposed", "recommended_code": "6109.10.00.14", "confidence": 0.75, "code_check": { "status": "valid", "code": "6109.10.00.12", "description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > T-shirts, all white, short hemmed sleeves, hemmed bottom, crew or round neckline, or V-neck, without pockets, trim or embroidery", "detail": "Valid declarable code", "comparison": "same_hs6" }, "final_code": null }, { "row_index": 3, "raw": { "description": "Cotton terry bath towel, 70 x 140 cm", "material": "cotton", "origin": "IN", "sku": "EXAMPLE-TWL-014", "hts_code": "" }, "status": "done", "issues": [], "duplicate_of": null, "lookup_id": "78058903e9d24902b9793f55cb92284b", "attempts": 1, "error": null, "result_status": "proposed", "recommended_code": "6302.60.00.10", "confidence": 0.74, "code_check": {}, "final_code": null } ] ``` Try it: https://docs.htspilot.com/explorer/#/Batches/listBatchRows ## Edit a row `PATCH /api/batches/{batch_id}/rows/{row_index}` **Changes data in your account when executed.** `values` maps column names of the file to their new text. `row_index` is the row number in the file (the first data row is 2). **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | | `row_index` | integer | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `values` | object | Yes | - | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X PATCH "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/rows/2" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "values": { "description": "Men'\''s T-shirt, knitted, 100% cotton, short sleeves" } }' ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.702951Z", "filename": "products.csv", "status": "queued", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "dataset_version": "2026-SAMPLE", "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "final": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "lang": "en" }, "total_rows": 3, "processed_rows": 3, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "done": 2, "pending": 1 }, "preview": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/editBatchRow ## Retry the failed rows `POST /api/batches/{batch_id}/retry` **Changes data in your account when executed.** **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Returns** `200` with `BatchOut`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/retry" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "id": "6a84a87996544d39b99b76e83d59486c", "created_at": "2026-10-03T19:48:03.470967", "updated_at": "2026-10-03T19:48:03.747413", "filename": "products.csv", "status": "completed", "columns": [ "description", "material" ], "column_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "market": "US", "dataset_version": "2026-SAMPLE", "validation": { "suggested_mapping": { "description": "description", "material": "material", "origin": "origin", "sku": "sku", "existing_code": "hts_code" }, "precheck": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "final": { "total": 3, "empty": 0, "examples": 0, "invalid": 0, "duplicates": 0, "valid": 3, "warnings": 0, "issues": [] }, "lang": "en", "retried_rows": 0 }, "total_rows": 3, "processed_rows": 3, "failed_rows": 0, "error": null, "save_to_catalog": false, "counts": { "done": 3 }, "preview": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/retryBatch ## Download the results `GET /api/batches/{batch_id}/export` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `batch_id` | string | - | **Returns** `200` with a file (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`). **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/batches/6a84a87996544d39b99b76e83d59486c/export" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -o "products_hts_results.xlsx" ``` **Example response** `200` ```json { "file": true, "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "content_disposition": "attachment; filename=\"products_hts_results.xlsx\"", "bytes": 6843 } ``` Try it: https://docs.htspilot.com/explorer/#/Batches/exportBatch The file is an Excel workbook in the language of the request ([Languages](/languages.md)). # Catalog > The shared SKU catalog - keep it in step with an ERP or PIM, check the codes in use against the active tariff schedule, and read the alerts raised when a schedule changes. The catalog holds your products with the tariff code each one uses. It answers two questions an ERP or PIM integration has: is the code we use still a valid line of the current schedule, and which products are affected when the schedule changes. A SKU is identified by `sku` and `market` together: the same `sku` can exist once for `US`, once for `EU` and once for `VN`. Creating a SKU that exists updates it, so a sync can be repeated safely. ## The SKU object | Field | Type | Meaning | | --- | --- | --- | | `id` | integer | The id of the SKU in the catalog | | `sku` | string | Your SKU code, up to 128 characters | | `market` | string | `US`, `EU` or `VN` | | `description`, `material`, `use`, `composition`, `origin`, `notes` | string | The product, as on a lookup | | `origin_label` | string | The country of origin, named in the language of the reader | | `external_ref` | string | Your own reference, for example the id in the ERP. Stored and returned as sent | | `current_code` | string or null | The code in use | | `code_status` | string | `valid` (a declarable line of the active schedule), `not_declarable` (a heading or a line that is not declarable), `invalid` (not in the schedule), `changed` or `expired` (affected by a new version), or `unknown` (no code) | | `code_status_label`, `code_status_detail` | string | The status in words | | `checked_version`, `checked_at` | string or null | The tariff version the code was checked against, and when | | `suggested_code` | string or null | The code of the latest lookup for this SKU | | `last_lookup_id` | string or null | That lookup | | `created_at`, `updated_at` | string | ISO 8601, UTC | | `alerts` | array | The latest 50 alerts of the SKU, acknowledged or not. Only on `GET /api/skus/{sku_id}` | ## The alert object An alert is raised for a SKU when a new version of the tariff schedule removes or changes its code. Each alert has an `id`, the `sku_id`, a `kind` (`code_removed`, `code_changed` or `code_invalid`), a `message`, `created_at`, and `acknowledged_at` with `acknowledged_by` once someone has dealt with it. Raising one sends the webhook event `sku.alert` ([Webhooks](/webhooks.md)). ## Create or update a SKU `POST /api/skus` **Changes data in your account when executed.** A SKU is identified by `sku` and `market`. `current_code`, when given, is checked against the active tariff schedule of the market. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `sku` | string | Yes | Up to 128 characters | | `market` | string | No | Up to 8 characters, default `"US"` | | `description` | string | Yes | Up to 2000 characters | | `material` | string | No | Up to 500 characters | | `use` | string | No | Up to 500 characters | | `composition` | string | No | Up to 500 characters | | `origin` | string | No | Up to 100 characters | | `notes` | string | No | Up to 1000 characters | | `external_ref` | string | No | Up to 255 characters | | `current_code` | string or null | No | Up to 24 characters | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/skus" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men'\''s T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12" }' ``` **Example response** `200` ```json { "id": 1, "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "origin_label": "Vietnam (VN)", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12", "code_status": "valid", "code_status_label": "Valid", "code_status_detail": "Valid declarable code", "checked_version": "2026-SAMPLE", "checked_at": "2026-10-03T19:48:03.962473+00:00", "suggested_code": null, "last_lookup_id": null, "created_at": "2026-10-03T19:48:03.962473+00:00", "updated_at": "2026-10-03T19:48:03.962473+00:00" } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/createSku ## Sync many SKUs `POST /api/skus/bulk` **Changes data in your account when executed.** Up to 5000 items per call. With `check_codes` every `current_code` is checked against the active tariff schedule. With `classify_missing` the SKUs without a code are classified in the background, which spends credits. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `items` | SkuIn[] | Yes | - | | `check_codes` | boolean | No | Default `true` | | `classify_missing` | boolean | No | Classify SKUs without a code (in the background). Default `false` | **`items`**: SkuIn | Name | Type | Required | Description | | --- | --- | --- | --- | | `sku` | string | Yes | Up to 128 characters | | `market` | string | No | Up to 8 characters, default `"US"` | | `description` | string | Yes | Up to 2000 characters | | `material` | string | No | Up to 500 characters | | `use` | string | No | Up to 500 characters | | `composition` | string | No | Up to 500 characters | | `origin` | string | No | Up to 100 characters | | `notes` | string | No | Up to 1000 characters | | `external_ref` | string | No | Up to 255 characters | | `current_code` | string or null | No | Up to 24 characters | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/skus/bulk" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men'\''s T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12" }, { "sku": "EXAMPLE-TWL-014", "market": "US", "description": "Cotton terry bath towel, 70 x 140 cm", "origin": "IN", "external_ref": "ERP-100046" } ], "check_codes": true, "classify_missing": false }' ``` **Example response** `200` ```json { "upserted": 2, "classification_queued": 0, "examples_skipped": 0, "items": [ { "id": 1, "sku": "EXAMPLE-TSH-001", "market": "US", "code_status": "valid" }, { "id": 2, "sku": "EXAMPLE-TWL-014", "market": "US", "code_status": "unknown" } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/syncSkus The answer lists each item with its `id` and `code_status`. `classify_missing` creates a lookup for every SKU without a code: the lookups can be charged, the call needs the plan feature `feature.batch`, and the whole call is refused when the count is over the plan's row limit. The call itself never answers `402`: a lookup that cannot be charged is not created, and its SKU stays without a suggested code. The suggested codes arrive as the lookups finish: read them from the SKUs (`suggested_code`, `last_lookup_id`). These lookups send no `lookup.completed` event. ## List SKUs `GET /api/skus` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string or null | No | Up to 200 characters | | `market` | string or null | No | - | | `code_status` | string or null | No | - | | `has_code` | boolean or null | No | True: only SKUs with an existing code; false: only without | | `has_alert` | boolean or null | No | True: only SKUs with an open alert; false: only without | | `page` | integer | No | Minimum 1, default `1` | | `page_size` | integer | No | Minimum 1, maximum 500, default `50` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/skus?page=1&page_size=50" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "items": [ { "id": 1, "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "origin_label": "Vietnam (VN)", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12", "code_status": "valid", "code_status_label": "Valid", "code_status_detail": "Valid declarable code", "checked_version": "2026-SAMPLE", "checked_at": "2026-10-03T19:48:03.974492", "suggested_code": null, "last_lookup_id": null, "created_at": "2026-10-03T19:48:03.962473", "updated_at": "2026-10-03T19:48:03.974492", "open_alerts": 0 }, { "id": 2, "sku": "EXAMPLE-TWL-014", "market": "US", "description": "Cotton terry bath towel, 70 x 140 cm", "material": "", "use": "", "composition": "", "origin": "IN", "origin_label": "India (IN)", "notes": "", "external_ref": "ERP-100046", "current_code": null, "code_status": "unknown", "code_status_label": "No code", "code_status_detail": "", "checked_version": "", "checked_at": null, "suggested_code": null, "last_lookup_id": null, "created_at": "2026-10-03T19:48:03.974492", "updated_at": "2026-10-03T19:48:03.974492", "open_alerts": 0 } ], "total": 2, "page": 1, "page_size": 50, "counts": { "unknown": 1, "valid": 1 } } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/listSkus ## Get a SKU `GET /api/skus/{sku_id}` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `sku_id` | integer | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/skus/1" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "id": 1, "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "origin_label": "Vietnam (VN)", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12", "code_status": "valid", "code_status_label": "Valid", "code_status_detail": "Valid declarable code", "checked_version": "2026-SAMPLE", "checked_at": "2026-10-03T19:48:03.974492", "suggested_code": null, "last_lookup_id": null, "created_at": "2026-10-03T19:48:03.962473", "updated_at": "2026-10-03T19:48:03.974492", "alerts": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/getSku ## Replace a SKU `PUT /api/skus/{sku_id}` **Changes data in your account when executed.** `sku` and `market` identify the SKU and are not changed. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `sku_id` | integer | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `sku` | string | Yes | Up to 128 characters | | `market` | string | No | Up to 8 characters, default `"US"` | | `description` | string | Yes | Up to 2000 characters | | `material` | string | No | Up to 500 characters | | `use` | string | No | Up to 500 characters | | `composition` | string | No | Up to 500 characters | | `origin` | string | No | Up to 100 characters | | `notes` | string | No | Up to 1000 characters | | `external_ref` | string | No | Up to 255 characters | | `current_code` | string or null | No | Up to 24 characters | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X PUT "https://htspilot.com/api/skus/1" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men'\''s T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12" }' ``` **Example response** `200` ```json { "id": 1, "sku": "EXAMPLE-TSH-001", "market": "US", "description": "Men's T-shirt, knitted, 100% cotton, short sleeves", "material": "cotton", "use": "apparel", "composition": "100% cotton", "origin": "VN", "origin_label": "Vietnam (VN)", "notes": "", "external_ref": "ERP-100045", "current_code": "6109.10.00.12", "code_status": "valid", "code_status_label": "Valid", "code_status_detail": "Valid declarable code", "checked_version": "2026-SAMPLE", "checked_at": "2026-10-03T19:48:04.009855+00:00", "suggested_code": null, "last_lookup_id": null, "created_at": "2026-10-03T19:48:03.962473", "updated_at": "2026-10-03T19:48:03.974492" } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/updateSku ## Delete a SKU `DELETE /api/skus/{sku_id}` **Deletes for real when executed, and cannot be undone.** Needs the reviewer or the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `sku_id` | integer | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X DELETE "https://htspilot.com/api/skus/1" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "ok": true } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/deleteSku ## Classify a SKU `POST /api/skus/{sku_id}/classify` **Changes data in your account when executed.** Runs a lookup for the SKU and stores the result as its suggested code. Spends credits. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `sku_id` | integer | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/skus/1/classify" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "lookup_id": "e9410baca06d4a22ad691e99eacb9f4e", "status": "proposed", "recommended_code": "6109.10.00.14", "confidence": 0.75 } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/classifySku Unlike `POST /api/classify`, this call waits for the result and answers with the outcome. The lookup has the kind `sku` and sends no `lookup.completed` event. ## Check the codes in use `POST /api/skus/check` **Changes data in your account when executed.** Checks every SKU that has a `current_code`, or only those of one market or the ones listed in `sku_ids`. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `market` | string or null | No | - | | `sku_ids` | integer[] or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/skus/check" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "market": "US" }' ``` **Example response** `200` ```json { "checked": 1, "counts": { "valid": 1 } } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/checkSkuCodes ## Download the import template `GET /api/skus/template` An .xlsx file with the import columns and their lists: the countries of origin and the markets that are loaded. **Returns** `200` with a file (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`). **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/skus/template" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -o "sku_catalog_template.xlsx" ``` **Example response** `200` ```json { "file": true, "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "content_disposition": "attachment; filename=\"sku_catalog_template.xlsx\"", "bytes": 11660 } ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/getSkuTemplate ## List alerts `GET /api/alerts` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `open_only` | boolean | No | Default `true` | | `sku_id` | integer or null | No | - | | `limit` | integer | No | Minimum 1, maximum 1000, default `200` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/alerts" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json [] ``` Try it: https://docs.htspilot.com/explorer/#/Catalog/listAlerts ## Acknowledge an alert `POST /api/alerts/{alert_id}/ack` **Changes data in your account when executed.** Needs the reviewer or the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `alert_id` | integer | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/alerts/1/ack" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` No example answer was captured for this operation. Try it: https://docs.htspilot.com/explorer/#/Catalog/acknowledgeAlert # Tariff data > The loaded tariff schedules - search and browse codes, changes between versions, duty and landed cost estimates, compliance hints and CBP rulings. These operations read the tariff schedules the service has loaded, and compute estimates from them. None of them creates a lookup, so none spends credits. A schedule is loaded as a dataset: one market (`US`, `EU` or `VN`) in one version. Each market has one active version, which is what a call uses when it names no `version`. Duty and landed cost figures are estimates for one line and one origin. They are not a duty assessment, and they leave out what the estimate cannot know: the answer lists what it applied, what may apply and which inputs were missing. ## Markets `market` names the import market a call is about. It is a parameter of a classify request, of a SKU, of a batch and of every tariff data operation, and each lookup records the market it was classified against. | `market` | Schedule | Currency of an estimate | | --- | --- | --- | | `US` | Harmonized Tariff Schedule of the United States | USD | | `EU` | Combined Nomenclature and TARIC | EUR | | `VN` | Vietnam import and export tariff | VND | `national_digits` of a dataset says how many digits a declarable line has in that schedule. `GET /api/datasets` lists the schedules that are loaded, with the version that is active for each market. A call without `market` uses the default market of the service (the duty estimate and the compliance hints default to `US`); send it explicitly. A lookup for Vietnam is the same call with `"market": "VN"`. This is the outcome of one, from the examples (the lookup object shortened to the fields that differ): ```json { "id": "becc6ae4b61c4600b98787bed190cfdd", "market": "VN", "dataset_version": "2026-04-05", "status": "proposed", "recommended_code": "6109.10.10", "recommended_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets and other vests, knitted or crocheted > Of cotton > For men or boys", "hs6": "610910", "confidence": 0.72, "language": "en" } ``` ## The dataset object | Field | Type | Meaning | | --- | --- | --- | | `id` | string | The id of the dataset | | `market` | string | `US`, `EU` or `VN` | | `version` | string | The version, as lookups record it in `dataset_version` | | `name` | string | The name of the schedule | | `national_digits` | integer | How many digits a declarable line has in this market | | `source_name`, `source_url` | string | Where the data comes from | | `is_demo` | boolean | Sample data, not the published schedule | | `active` | boolean | The version in use for this market | | `entry_count` | integer | Lines in the dataset | | `loaded_at`, `effective_date`, `superseded_at` | string or null | When it was loaded, from when it applies, and when a newer version replaced it | ## The tariff line | Field | Type | Meaning | | --- | --- | --- | | `code` | string | The code, formatted as the schedule writes it | | `hs6` | string | The first six digits | | `level` | string | The level of the line in the tree, for example `chapter` or `heading` | | `is_leaf` | boolean | A declarable line. Only these can be a recommended or a final code | | `description`, `full_description` | string | The official text of the line, alone and with its parents. Not translated | | `duty_rate`, `special_rate`, `column2_rate` | string | The rates as published | | `unit` | string | The unit of quantity | ## List the datasets `GET /api/datasets` **Returns** `200` with `DatasetOut[]`. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/datasets" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json [ { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "national_digits": 10, "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa.", "source_url": "https://hts.usitc.gov/", "is_demo": true, "active": true, "entry_count": 147, "loaded_at": "2026-10-03T19:48:02.737682", "official_domains": [ "hts.usitc.gov", "usitc.gov" ], "notes": "", "effective_date": "2026-01-01", "superseded_at": null } ] ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/listDatasets ## List the lines of a dataset `GET /api/datasets/{dataset_id}/entries` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `dataset_id` | string | - | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | No | Up to 16 characters | | `q` | string | No | Up to 200 characters | | `limit` | integer | No | Minimum 1, maximum 500, default `50` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/datasets/76235cf1803b4782be050a08c64c3cad/entries?code=6109&limit=50" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json [ { "code": "6109", "hs6": "6109", "level": "heading", "is_leaf": false, "description": "T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "duty_rate": "", "special_rate": "", "column2_rate": "", "unit": "" }, { "code": "6109.10.00", "hs6": "610910", "level": "national", "is_leaf": false, "description": "Of cotton", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton", "duty_rate": "16.5%", "special_rate": "Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)", "column2_rate": "90%", "unit": "" } ] ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/listDatasetEntries ## Search the tariff `GET /api/hts/search` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | Yes | Up to 200 characters | | `market` | string | No | - | | `version` | string or null | No | - | | `limit` | integer | No | Minimum 1, maximum 200, default `50` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/hts/search?q=cotton%20t-shirt&market=US&limit=20" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "effective_date": "2026-01-01", "source_url": "https://hts.usitc.gov/", "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa." }, "mode": "keyword", "items": [ { "code": "6109.10.00.14", "code_digits": "6109100014", "hs6": "610910", "level": "national", "is_leaf": true, "parent_code": "61091000", "description": "Other T-shirts", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Men's or boys' > Other T-shirts", "unit": "doz. kg", "duty_rate": "16.5%", "special_rate": "Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)", "column2_rate": "90%", "additional_duties": "", "official_url": "https://hts.usitc.gov/", "score": 5.16 }, { "code": "6109.10.00.40", "code_digits": "6109100040", "hs6": "610910", "level": "national", "is_leaf": true, "parent_code": "61091000", "description": "T-shirts", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton > Women's or girls' > T-shirts", "unit": "doz. kg", "duty_rate": "16.5%", "special_rate": "Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)", "column2_rate": "90%", "additional_duties": "", "official_url": "https://hts.usitc.gov/", "score": 5.16 } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/searchTariff `q` is a code (or the start of one) or words. The answer says which it was read as, in `mode`. ## Browse the tree `GET /api/hts/tree` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string or null | No | Up to 24 characters | | `market` | string | No | - | | `version` | string or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/hts/tree?code=6109&market=US" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "effective_date": "2026-01-01", "source_url": "https://hts.usitc.gov/", "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa." }, "node": { "code": "6109", "code_digits": "6109", "hs6": "6109", "level": "heading", "is_leaf": false, "parent_code": "61", "description": "T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted", "unit": "", "duty_rate": "", "special_rate": "", "column2_rate": "", "additional_duties": "", "official_url": "https://hts.usitc.gov/" }, "ancestors": [ { "code": "61", "code_digits": "61", "hs6": "61", "level": "chapter", "is_leaf": false, "parent_code": null, "description": "Articles of apparel and clothing accessories, knitted or crocheted", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted", "unit": "", "duty_rate": "", "special_rate": "", "column2_rate": "", "additional_duties": "", "official_url": "https://hts.usitc.gov/" } ], "children": [ { "code": "6109.10.00", "code_digits": "61091000", "hs6": "610910", "level": "national", "is_leaf": false, "parent_code": "6109", "description": "Of cotton", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of cotton", "unit": "", "duty_rate": "16.5%", "special_rate": "Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)", "column2_rate": "90%", "additional_duties": "", "official_url": "https://hts.usitc.gov/" }, { "code": "6109.90", "code_digits": "610990", "hs6": "610990", "level": "subheading", "is_leaf": false, "parent_code": "6109", "description": "Of other textile materials", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > T-shirts, singlets, tank tops and similar garments, knitted or crocheted > Of other textile materials", "unit": "", "duty_rate": "", "special_rate": "", "column2_rate": "", "additional_duties": "", "official_url": "https://hts.usitc.gov/" } ], "siblings": [ { "code": "6110", "code_digits": "6110", "hs6": "6110", "level": "heading", "is_leaf": false, "parent_code": "61", "description": "Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted", "full_description": "Chapter 61: Articles of apparel and clothing accessories, knitted or crocheted > Sweaters, pullovers, sweatshirts, waistcoats (vests) and similar articles, knitted or crocheted", "unit": "", "duty_rate": "", "special_rate": "", "column2_rate": "", "additional_duties": "", "official_url": "https://hts.usitc.gov/" } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/getTariffTree Without `code`, the answer is the top of the tree. With one, it is that node with its ancestors, children and siblings. ## Estimate duty and landed cost `POST /api/hts/duty-estimate` An estimate for one declarable line and one origin. It is not a duty assessment. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | Yes | Up to 24 characters | | `market` | string | No | Up to 8 characters, default `"US"` | | `version` | string or null | No | - | | `import_date` | string or null | No | Expected import date: selects the tariff schedule version in effect on that day | | `origin` | string | No | Up to 100 characters | | `customs_value` | number | Yes | Customs value in the estimate's `currency` (USD; EUR for EU; VND for VN). Minimum 0 | | `quantity` | number or null | No | Quantity in the unit of measure (pieces, pairs, dozens..). Minimum 0 | | `weight_kg` | number or null | No | Minimum 0 | | `freight` | number | No | Minimum 0, default `0` | | `insurance` | number | No | Minimum 0, default `0` | | `mode` | string | No | Pattern `^(sea\|air\|land)$`, default `"sea"` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/hts/duty-estimate" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "code": "6109.10.00.12", "market": "US", "origin": "VN", "customs_value": 10000, "quantity": 1200, "freight": 800, "insurance": 50, "mode": "sea" }' ``` **Example response** `200` (long lists and texts are cut) ```json { "available": true, "code": "6109.10.00.12", "market": "US", "version": "2026-SAMPLE", "effective_date": "2026-01-01", "origin": "VN", "country": "VN", "rate_basis": "general", "rate_text": "16.5%", "general_rate": "16.5%", "special_rate": "Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)", "column2_rate": "90%", "duty": 1650, "duty_partial": 1650, "duty_breakdown": [ "16.5% × 10,000.00 = 1,650.00" ], "missing_inputs": [], "fees": [ { "name": "Merchandise Processing Fee (formal entry)", "amount": 34.64, "basis": "0.3464% (min 33.58, max 651.5)" }, { "name": "Harbor Maintenance Fee (sea)", "amount": 12.5, "basis": "0.125%" } ], "fees_total": 47.14, "customs_value": 10000, "freight": 800, "insurance": 50, "landed_cost": 12547.14, "duty_min": null, "landed_cost_min": null, "duty_max": 1650, "landed_cost_max": 12547.14, "additional_duties": { "country": "VN", "applied": [], "may_apply": [], "superseded": [], "exemptions": [], "notes": [], "additional_pct": 0, "max_additional_pct": 0, "complete": true }, "preferences": [ { "program": "AU", "name": "US–Australia FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." }, { "program": "BH", "name": "US–Bahrain FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." } ], "warnings": [ "Chapter 99 additional duties are estimated from the loaded HTS text (origin, conditions, U.S. note lists, exemptions); AD/CVD and quotas are not included – verify with a customs broker before quoting." ], "is_estimate": true, "currency": "USD", "compliance": { "code": "6109.10.00.12", "market": "US", "origin": "VN", "country": "VN", "pga": [ { "agency": "CPSC / FTC", "requirement": "Textiles and apparel: flammability standard (16 CFR 1610), fiber content and origin labelling (Textile Act)" } ], "adcvd": [], "preferences": [ { "program": "AU", "name": "US–Australia FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." }, { "program": "BH", "name": "US–Bahrain FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." } ], "additional_duties_note": "Check HTSUS Chapter 99 (Section 301 for goods of Chinese origin, Section 232 for steel/aluminum/copper and derivatives, reciprocal tariffs) – applied on top of the regular duty rate.", "links": { "adcvd": "https://access.trade.gov/", "adcvd_cbp": "https://trade.cbp.dhs.gov/ace/adcvd/adcvd-public/#", "pga": "https://www.cbp.gov/trade/basic-import-export/e-commerce/pga" }, "disclaimer": "Reference list maintained by the operations team – NOT exhaustive and subject to change. PGA and AD/CVD scope depends on the goods description and the agencies' own texts, not on the HTS code alone." }, "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "effective_date": "2026-01-01", "source_url": "https://hts.usitc.gov/", "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa." } } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/estimateDuty `code` must be a declarable line. `customs_value` is in the currency of the estimate: USD for `US`, EUR for `EU`, VND for `VN`. For Vietnam the estimate uses the MFN rate only: rates of trade agreements by origin, the ordinary rate and the taxes on import are not estimated, and the answer says so in its warnings. `import_date` selects the version in effect on that day. The estimate is one amount when it can be: `duty` and `landed_cost`. They are `null` in three cases, and the answer then says what it has instead: | Case | What the answer carries | | --- | --- | | An input the rate needs was not sent | `missing_inputs` names it, and `duty_partial` holds what could be computed | | The published rate of a Vietnam line names two rates (a quota rate, a rate for part of the year) | A range: `duty_min` and `landed_cost_min` at the lower rate (also in `duty_partial`), `duty_max` and `landed_cost_max` at the higher one. Which applies depends on the import | | The published rate carries a qualifier without a second rate (Vietnam), or cannot be read | Only `duty_partial`, which holds what could be computed; the range fields are `null`, and `warnings` say why | The four range fields, `duty_min`, `duty_max`, `landed_cost_min` and `landed_cost_max`, are part of an estimate for `US` and `VN` only. An estimate for `EU` does not carry them: it has `duty` and `landed_cost`, or `null` with `duty_partial`, `missing_inputs` and `warnings`. For `EU`, `duty` is also `null` when a trade defence measure applies to the origin: such a measure can differ by company, so it is not added to the amount, and a warning names the measure, the origin, its rate or range of rates and its legal base. Where they exist, `duty_max` and `landed_cost_max` are filled whenever an upper bound is known. For an estimate that is one amount they are the amounts if every additional duty that may apply does, and equal `duty` and `landed_cost` when none may. `duty_min` and `landed_cost_min` are `null` unless the answer is a range. ## Compliance hints `GET /api/hts/compliance` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | Yes | Up to 24 characters | | `market` | string | No | Default `"US"` | | `version` | string or null | No | - | | `origin` | string | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/hts/compliance?code=6109.10.00.12&market=US&origin=CN" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "dataset": { "id": "76235cf1803b4782be050a08c64c3cad", "market": "US", "version": "2026-SAMPLE", "name": "HTSUS – bộ dữ liệu mẫu (DEMO, không phải dữ liệu chính thức)", "is_demo": true, "effective_date": "2026-01-01", "source_url": "https://hts.usitc.gov/", "source_name": "Mô phỏng theo cấu trúc bản xuất JSON của USITC (hts.usitc.gov). Dòng thuế 8–10 số và mô tả được rút gọn/minh họa." }, "code": "6109.10.00.12", "market": "US", "origin": "CN", "country": "CN", "pga": [ { "agency": "CPSC / FTC", "requirement": "Textiles and apparel: flammability standard (16 CFR 1610), fiber content and origin labelling (Textile Act)" } ], "adcvd": [], "preferences": [ { "program": "AU", "name": "US–Australia FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." }, { "program": "BH", "name": "US–Bahrain FTA", "rate": "Free", "applies_to_origin": false, "note": "The agreement's rules of origin and documentation must be met." } ], "additional_duties_note": "Check HTSUS Chapter 99 (Section 301 for goods of Chinese origin, Section 232 for steel/aluminum/copper and derivatives, reciprocal tariffs) – applied on top of the regular duty rate.", "links": { "adcvd": "https://access.trade.gov/", "adcvd_cbp": "https://trade.cbp.dhs.gov/ace/adcvd/adcvd-public/#", "pga": "https://www.cbp.gov/trade/basic-import-export/e-commerce/pga" }, "disclaimer": "Reference list maintained by the operations team – NOT exhaustive and subject to change. PGA and AD/CVD scope depends on the goods description and the agencies' own texts, not on the HTS code alone." } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/getComplianceHints Hints on agency requirements, anti-dumping and countervailing duty cases and trade preferences for the line and the origin. The answer carries its own `disclaimer` and `links`. ## Read a CBP ruling `GET /api/hts/rulings/{number}` **Path parameters** | Name | Type | Description | | --- | --- | --- | | `number` | string | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/hts/rulings/N301234" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` No example answer was captured for this operation. Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/getRuling The text is fetched from the CBP ruling service when you ask for it: `502` when that service does not answer, `404` when the ruling does not exist. ## Versions that changed a schedule `GET /api/tariff-changes/versions` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `market` | string or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/tariff-changes/versions" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json [] ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/listTariffChangeVersions ## Changes between two versions `GET /api/tariff-changes` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `market` | string | No | Default `"US"` | | `to_version` | string or null | No | - | | `change_type` | string or null | No | - | | `q` | string or null | No | Up to 50 characters | | `limit` | integer | No | Minimum 1, maximum 2000, default `200` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/tariff-changes?market=US" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "versions": [], "to_version": null, "summary": {}, "items": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/listTariffChanges The kinds of change are `added`, `removed`, `description_changed` and `duty_changed`. When a new version becomes active, the webhook event `tariff.changed` carries the counts ([Webhooks](/webhooks.md)). ## Countries of origin `GET /api/countries` The values `origin` accepts on a lookup and on a SKU. **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/countries" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json { "countries": [ { "code": "AF", "label": "Afghanistan (AF)" }, { "code": "AX", "label": "Åland Islands (AX)" } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Tariff%20data/listCountries # Usage > The credit balance of the account, what was spent, the credit ledger, and what the plan of the account allows. These operations show the credit balance, the history and the plan of an account ([Rate limits and quotas](/rate-limits.md#credits)). A key reads the usage of the account that created it. The example answers of this page are those of a member account, and their `pricing` block and credit figures are illustrative: they are not the settings of the service. ## The usage object | Field | Type | Meaning | | --- | --- | --- | | `credits` | number | The balance | | `unlimited` | boolean | The account is exempt from credits | | `pricing` | object | `enabled`, `credits_per_lookup` and `signup_credits`. Illustrative in the examples | | `from`, `to` | string | The period the figures cover. The last 30 days by default | | `totals` | object | `lookups` and `credits_used` in the period | | `daily` | array | The same per day | | `previous` | object | The totals of the period before, for comparison | | `by_kind`, `by_market`, `by_status` | object | Lookups of the period, split | | `ledger` | object | Credits `added`, `removed` and `net` in the period, and the same per kind | | `transactions` | array | The latest 50 ledger rows of the account | ## The ledger row | Field | Type | Meaning | | --- | --- | --- | | `id` | integer | The id of the row | | `created_at` | string | ISO 8601, UTC | | `kind`, `kind_label` | string | `signup` (the first credits of an account), `grant` (credits added or adjusted) or `usage` (the fee of a lookup) | | `amount` | number | Positive for a grant, negative for a fee | | `balance_after` | number | The balance after this row | | `lookup_id` | string or null | The lookup a fee belongs to | | `note` | string | A note on a grant | | `user_id`, `user_name`, `user_email` | string | The account | ## The plan object `plan` is the plan of the account, or `null` for an account without one; an account without a plan is not restricted. `restricted` is returned next to it. `entitlements` lists every feature and limit: | Field | Type | Meaning | | --- | --- | --- | | `key` | string | The name used in a plan refusal ([Errors](/errors.md#plan-refusals)): `feature.batch`, `batch_max_rows` and so on | | `kind` | string | `feature` (on or off) or `limit` (a number) | | `category`, `label`, `unit_label` | string | For display, in the language of the reader | | `enabled` | boolean | For a feature | | `value`, `unlimited` | integer, boolean | For a limit: the number, or `unlimited` set to `true` | Not every entitlement is enforced on API calls; [Rate limits and quotas](/rate-limits.md#plan-limits) lists the ones that are. ## Get usage `GET /api/usage/me` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `date_from` | string or null | No | - | | `date_to` | string or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/usage/me" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200`. Example for a member account. Credit figures and the pricing block are illustrative. ```json { "user_id": "4363f5a21c0c4faf929abadcb1f82806", "name": "Data Entry Clerk", "email": "entry@example.com", "role": "entry", "role_label": "User", "credits": 99, "unlimited": false, "active": true, "last_login_at": "2026-10-03T19:48:04.090553", "pricing": { "enabled": true, "credits_per_lookup": 1, "signup_credits": 100 }, "from": "2026-09-05", "to": "2026-10-04", "totals": { "lookups": 1, "credits_used": 1 }, "llm": { "llm_calls": 0, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "charged_lookups": 1, "avg_processing_ms": 19 }, "daily": [ { "day": "2026-10-03", "lookups": 1, "credits_used": 1, "tokens": 0 } ], "previous": { "from": "2026-08-06", "to": "2026-09-04", "lookups": 0, "credits_used": 0, "total_tokens": 0 }, "by_kind": [ { "key": "single", "lookups": 1, "credits_used": 1 } ], "by_market": [ { "key": "US", "lookups": 1, "credits_used": 1 } ], "by_status": [ { "key": "proposed", "lookups": 1, "credits_used": 1 } ], "ledger": { "added": 100, "removed": 1, "net": 99, "by_kind": [ { "kind": "signup", "kind_label": "Initial credits", "count": 1, "added": 100, "removed": 0 }, { "kind": "usage", "kind_label": "Lookup", "count": 1, "added": 0, "removed": 1 } ] }, "transactions": [ { "id": 3, "created_at": "2026-10-03T19:48:04.121993", "kind": "usage", "kind_label": "Lookup", "amount": -1, "balance_after": 99, "lookup_id": "93ee3f02698847b2a32a74b60a5eead1", "note": "", "created_by_name": null }, { "id": 2, "created_at": "2026-10-03T19:48:02.727052", "kind": "signup", "kind_label": "Initial credits", "amount": 100, "balance_after": 100, "lookup_id": null, "note": "", "created_by_name": null } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Usage/getMyUsage ## List the credit ledger `GET /api/usage/ledger` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `date_from` | string or null | No | - | | `date_to` | string or null | No | - | | `user_id` | string or null | No | Up to 32 characters | | `kind` | one of: signup, grant, usage or null | No | - | | `page` | integer | No | Minimum 1, default `1` | | `page_size` | integer | No | Minimum 1, maximum 200, default `25` | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/usage/ledger?page=1&page_size=25" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200`. Example for a member account. Credit figures and the pricing block are illustrative. ```json { "items": [ { "id": 3, "created_at": "2026-10-03T19:48:04.121993", "kind": "usage", "kind_label": "Lookup", "amount": -1, "balance_after": 99, "lookup_id": "93ee3f02698847b2a32a74b60a5eead1", "note": "", "created_by_name": null, "user_id": "4363f5a21c0c4faf929abadcb1f82806", "user_name": "Data Entry Clerk", "user_email": "entry@example.com", "tokens": 0 }, { "id": 2, "created_at": "2026-10-03T19:48:02.727052", "kind": "signup", "kind_label": "Initial credits", "amount": 100, "balance_after": 100, "lookup_id": null, "note": "", "created_by_name": null, "user_id": "4363f5a21c0c4faf929abadcb1f82806", "user_name": "Data Entry Clerk", "user_email": "entry@example.com", "tokens": 0 } ], "total": 2, "page": 1, "page_size": 25, "totals": { "added": 100, "removed": 1, "net": 99 } } ``` Try it: https://docs.htspilot.com/explorer/#/Usage/listLedger A key with the `admin` role sees the rows of every account and can filter by `user_id`; other keys see their own. ## Download the ledger `GET /api/usage/ledger/export` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `date_from` | string or null | No | - | | `date_to` | string or null | No | - | | `user_id` | string or null | No | Up to 32 characters | | `kind` | one of: signup, grant, usage or null | No | - | **Returns** `200` with a file (`text/csv`). **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/usage/ledger/export" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -o "credit-ledger-2026-10-04.csv" ``` **Example response** `200`. Example for a member account. Credit figures and the pricing block are illustrative. ```json { "file": true, "content_type": "text/csv", "content_disposition": "attachment; filename=\"credit-ledger-2026-10-04.csv\"", "bytes": 260 } ``` Try it: https://docs.htspilot.com/explorer/#/Usage/exportLedger ## Get the plan `GET /api/plans/me` **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/plans/me" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200`. Example for a member account. Credit figures and the pricing block are illustrative. (long lists and texts are cut) ```json { "plan": null, "restricted": false, "entitlements": [ { "key": "feature.market_us", "kind": "feature", "category": "markets", "label": "US market (HTS)", "unit_label": null, "enabled": true }, { "key": "feature.market_eu", "kind": "feature", "category": "markets", "label": "EU market (CN and TARIC)", "unit_label": null, "enabled": true } ] } ``` Try it: https://docs.htspilot.com/explorer/#/Usage/getMyPlan # Webhook endpoints > Register, change, test and delete webhooks through the API, and read the deliveries of a webhook. These operations manage the webhooks themselves. What a webhook sends, how to verify it and how it retries is in [Webhooks](/webhooks.md). All of them need the `admin` role; creating, changing and testing also need the plan feature `feature.webhooks`. ## The webhook object | Field | Type | Meaning | | --- | --- | --- | | `id` | string | The id of the webhook | | `url` | string | Where deliveries are sent | | `events` | array | The event names it receives. Empty means every event | | `active` | boolean | A paused webhook receives nothing | | `created_at` | string | ISO 8601, UTC | | `last_status` | integer or null | The HTTP status your endpoint gave to the latest delivery | | `last_error` | string or null | Why the latest delivery failed | | `last_delivery_at` | string or null | When the latest delivery ended | | `secret` | string | The key of the signature. Only in the answer of `POST /api/webhooks` | ## The delivery object | Field | Type | Meaning | | --- | --- | --- | | `id` | integer | The delivery id, as sent in `X-HTS-Delivery` | | `event` | string | The event name | | `status_code` | integer or null | The HTTP status of the last attempt. `null` when no answer was received | | `error` | string or null | Why it failed: the start of your endpoint's answer, or a general reason | | `attempts` | integer | How many attempts were made | | `created_at` | string | ISO 8601, UTC | ## Register a webhook `POST /api/webhooks` **Changes data in your account when executed.** The answer contains `secret`, the key of the `X-HTS-Signature` header. It is shown only here. Leave `events` empty to receive every event. The URL must be `https` and resolve to a public address. Needs the admin role. **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | Yes | Up to 2083 characters | | `events` | string[] | No | Empty = all events | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/webhooks" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "url": "https://erp.example.com/hooks/hts-pilot", "events": [] }' ``` **Example response** `200` ```json { "id": "1a6befc49cb74bb99ba2220e865fcaea", "url": "https://erp.example.com/hooks/hts-pilot", "events": [], "active": true, "created_at": "2026-10-03T19:48:02.885012+00:00", "last_status": null, "last_error": null, "last_delivery_at": null, "secret": "0123456789abcdef0123456789abcdef0123456789abcdef" } ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/createWebhook ## List webhooks `GET /api/webhooks` Needs the admin role. **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/webhooks" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json [ { "id": "1a6befc49cb74bb99ba2220e865fcaea", "url": "https://erp.example.com/hooks/hts-pilot", "events": [], "active": true, "created_at": "2026-10-03T19:48:02.885012", "last_status": 200, "last_error": null, "last_delivery_at": "2026-10-03T19:48:04.339927" } ] ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/listWebhooks ## Change or pause a webhook `PATCH /api/webhooks/{hook_id}` **Changes data in your account when executed.** Needs the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `hook_id` | string | - | **Request body** (`application/json`) | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string or null | No | Up to 2083 characters | | `events` | string[] or null | No | - | | `active` | boolean or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X PATCH "https://htspilot.com/api/webhooks/1a6befc49cb74bb99ba2220e865fcaea" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "active": false }' ``` **Example response** `200` ```json { "id": "1a6befc49cb74bb99ba2220e865fcaea", "url": "https://erp.example.com/hooks/hts-pilot", "events": [], "active": false, "created_at": "2026-10-03T19:48:02.885012", "last_status": 200, "last_error": null, "last_delivery_at": "2026-10-03T19:48:04.339927" } ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/updateWebhook Send only what changes. A new `url` is checked like a new webhook. The secret cannot be changed here; to rotate it, delete the webhook and register it again. ## Delete a webhook `DELETE /api/webhooks/{hook_id}` **Deletes for real when executed, and cannot be undone.** Needs the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `hook_id` | string | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X DELETE "https://htspilot.com/api/webhooks/1a6befc49cb74bb99ba2220e865fcaea" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "ok": true } ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/deleteWebhook ## Send a test event `POST /api/webhooks/{hook_id}/test` **Changes data in your account when executed.** Sends the event `ping` now, with the same body shape, headers and signature as a real event, and answers with the result. Needs the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `hook_id` | string | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl -X POST "https://htspilot.com/api/webhooks/1a6befc49cb74bb99ba2220e865fcaea/test" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "ok": true, "id": "1a6befc49cb74bb99ba2220e865fcaea", "url": "https://erp.example.com/hooks/hts-pilot", "events": [], "active": true, "created_at": "2026-10-03T19:48:02.885012", "last_status": 200, "last_error": null, "last_delivery_at": "2026-10-03T19:48:04.339927" } ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/testWebhook `ok` is `true` when your endpoint answered `2xx`. ## List deliveries `GET /api/webhooks/{hook_id}/deliveries` Needs the admin role. **Path parameters** | Name | Type | Description | | --- | --- | --- | | `hook_id` | string | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/webhooks/1a6befc49cb74bb99ba2220e865fcaea/deliveries" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` (long lists and texts are cut) ```json [ { "id": 7, "event": "ping", "status_code": 200, "error": null, "attempts": 1, "created_at": "2026-10-03T19:48:04.339927" }, { "id": 6, "event": "lookup.completed", "status_code": 200, "error": null, "attempts": 1, "created_at": "2026-10-03T19:48:04.125512" } ] ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/listWebhookDeliveries ## List event names `GET /api/webhooks/events` Needs the admin role. **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/webhooks/events" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "lookup.completed": "A lookup finished", "lookup.decided": "A reviewer approved / overrode / rejected a code", "batch.completed": "A batch file finished processing", "tariff.changed": "A new tariff version and code changes were detected", "sku.alert": "A SKU is affected by a tariff change" } ``` Try it: https://docs.htspilot.com/explorer/#/Webhooks/listWebhookEvents # Reports > Review productivity and quality for a period - lookups, decisions and decision times. One report: how many lookups were created in a period, how they ended, what reviewers decided and how long decisions took. It needs the `reviewer` or the `admin` role and the plan feature `feature.reports`. ## The report object | Field | Type | Meaning | | --- | --- | --- | | `from`, `to` | string | The period. The last 30 days by default | | `total_lookups` | integer | Lookups created in the period | | `by_status`, `by_decision` | object | Lookups per status and per decision | | `review_queue` | integer | Lookups in the review queue | | `ai_acceptance_rate` | number or null | Decisions with `approved`, divided by all decisions of the period. `null` without decisions | | `users` | array | Per account: lookups, batch rows, decisions of each kind, comments, average decision time | | `daily` | array | Lookups and decisions per day | | `kpis`, `previous` | object | Figures of the period, and of the period before | | `markets` | array | Figures per market | | `top_overridden` | array | Up to 10 entries about overridden codes | ## Get the productivity report `GET /api/reports/productivity` Needs the reviewer or the admin role. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `date_from` | string or null | No | - | | `date_to` | string or null | No | - | **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/reports/productivity" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "from": "2026-09-05", "to": "2026-10-04", "total_lookups": 8, "by_status": { "needs_info": 1, "proposed": 7 }, "by_decision": { "approved": 1 }, "review_queue": 1, "ai_acceptance_rate": 1, "users": [ { "user_id": "a7ba4a2440c7422fb695a1fd80d65ce5", "name": "Administrator", "role": "Admin", "lookups": 3, "batch_rows": 4, "approved": 1, "overridden": 0, "rejected": 0, "comments": 0, "avg_decision_hours": 0, "median_decision_hours": 0, "decisions": 1 }, { "user_id": "4363f5a21c0c4faf929abadcb1f82806", "name": "Data Entry Clerk", "role": "User", "lookups": 1, "batch_rows": 0, "approved": 0, "overridden": 0, "rejected": 0, "comments": 0, "avg_decision_hours": null, "median_decision_hours": null, "decisions": 0 } ], "daily": [ { "day": "2026-10-03", "count": 8, "auto_proposed": 7, "routed_to_review": 1, "decisions": 1 } ], "kpis": { "lookups": 8, "auto_proposed": 7, "routed_to_review": 1, "errors": 0, "auto_rate": 0.875, "decisions": 1, "approved": 1, "overridden": 0, "rejected": 0, "approval_rate": 1, "override_rate": 0, "reject_rate": 0, "median_decision_hours": 0 }, "previous": { "from": "2026-08-06", "to": "2026-09-04", "lookups": 0, "auto_proposed": 0, "routed_to_review": 0, "errors": 0, "auto_rate": null, "decisions": 0, "approved": 0, "overridden": 0, "rejected": 0, "approval_rate": null, "override_rate": null, "reject_rate": null, "median_decision_hours": null }, "markets": [ { "market": "US", "lookups": 8, "auto_proposed": 7, "routed_to_review": 1, "errors": 0, "approved": 1, "overridden": 0, "rejected": 0, "decisions": 1, "approval_rate": 1 } ], "top_overridden": [] } ``` Try it: https://docs.htspilot.com/explorer/#/Reports/getProductivityReport # System > Whether the HTS Pilot service is up, and which account and role an API key belongs to. Two operations for the first minutes of an integration and for monitoring it afterwards. ## Service status `GET /api/health` (no API key needed) Answers `200` while the service is up. `status` is `ok` or `degraded`, with the reasons in `issues`. No key is needed. **Returns** `200` with a JSON object, as in the example. **Errors**: `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/health" ``` **Example response** `200` ```json { "status": "ok", "environment": "development", "demo_mode": true, "sample_data_markets": [ "US" ], "issues": [], "background": { "query_embedding": "ok", "vn_tariff": "disabled" }, "version": null } ``` Try it: https://docs.htspilot.com/explorer/#/System/getHealth `GET /api/health` needs no key. It answers `200` while the service is up; `status` is `ok` or `degraded`, and `issues` names what is degraded. `background` reports things that never change `status`, and `version` names the running build (`null` when the environment does not name one). | `background` | Values | Meaning | | --- | --- | --- | | `vn_tariff` | `disabled`, `no_release`, `pending`, `loading`, `loaded`, `failed`, `differs`, `unknown` | The load of the bundled Vietnam tariff release. `differs`: the file of that release is not the one that was loaded, and nothing was loaded over it | | `query_embedding` | `ok`, `paused` | `paused`: lookups run on text search alone for the moment ([Lookups](/lookups.md#the-lookup-object), `result.retrieval`) | New keys and new values can be added: treat one you do not know as informational. The service status over time is published at . ## The account behind a key `GET /api/auth/me` **Returns** `200` with a JSON object, as in the example. **Errors**: `401` No valid API key was sent; `403` The role of the key, or the plan of the account, does not allow this call; `422` The request is not valid; `500` An unexpected failure. **Example request** ```bash curl "https://htspilot.com/api/auth/me" \ -H "X-API-Key: hts_EXAMPLE_KEY_REPLACE_ME" ``` **Example response** `200` ```json { "id": "a7ba4a2440c7422fb695a1fd80d65ce5", "name": "Administrator (API: Documentation examples)", "email": "admin@example.com", "role": "admin", "role_label": "Admin", "via": "api_key", "avatar_url": "", "credits": 0, "unlimited": true } ``` Try it: https://docs.htspilot.com/explorer/#/System/getMe | Field | Type | Meaning | | --- | --- | --- | | `id`, `name`, `email` | string | The account that created the key. `name` carries the name of the key | | `role`, `role_label` | string | The effective role of the key ([Authentication](/authentication.md#roles)) | | `via` | string | `api_key` for a request recognised by its key | | `credits`, `unlimited` | number, boolean | The balance, and whether the account is exempt | # Build with AI agents > What these pages offer to AI agents and coding assistants - every page as Markdown, llms.txt, the OpenAPI document, and ready-made skills that teach an agent to integrate the HTS Pilot API. If an AI agent or a coding assistant helps you integrate the API, give it these pages in a form it reads well. Everything here is plain files on this host: nothing calls an assistant service, and nothing here needs an account. ## Every page as Markdown Each page has a Markdown copy at the same address with `.md`: this page is at [/ai-agents.md](/ai-agents.md), the introduction at `/index.md`, the lookups reference at `/lookups.md`. The copy is complete: the generated parameter tables and the examples are in it. | Address | What | | --- | --- | | `/.md` | One page as Markdown | | [/llms.txt](/llms.txt) | The index: every page with a one-line description, and the OpenAPI document | | [/llms-full.txt](/llms-full.txt) | The whole documentation as one Markdown file, in reading order | | [/openapi.json](/openapi.json) | Every operation with its parameters and schemas | | [/skill.md](/skill.md) | The core skill below, as one file | | `/.well-known/api-catalog` | A machine-readable pointer to the OpenAPI document and these pages | An agent that asks for a page with the header `Accept: text/markdown` gets the Markdown at the page's own address, without knowing about `.md`: ```bash curl -H "Accept: text/markdown" https://docs.htspilot.com/authentication/ ``` Every page and every Markdown copy also names these entry points in a `Link` header. ## On every page The "Copy page" button at the top of a page copies its Markdown. The menu next to it offers "View as Markdown", "Copy for an AI assistant" (the Markdown with a short header: what the API is, its base URL, how to authenticate, and where the full documentation is) and "Download the agent skills". "Copy section", beside a heading, copies that section alone. ## Agent skills A skill is a folder an agent loads when a task calls for it: a `SKILL.md` with a `name` and a `description` that say when to use it, the steps, the pitfalls and how to verify the result, with scripts and reference pages next to it. Three are published here: | Skill | For | | --- | --- | | `hts-pilot-api` | Calling the API: authentication, classify then poll or receive a webhook, reading a lookup, errors, languages. With a client in Python and in JavaScript | | `hts-pilot-webhooks` | Receiving deliveries: verifying the signature over the raw body, answering fast, ignoring repeats. With a verifier and a small receiver in Python and in JavaScript | | `hts-pilot-batch` | Classifying a file of products: upload, column mapping, start, progress, results. With a command line in Python and in JavaScript | Download: - all three: [/skills/hts-pilot-skills.zip](/skills/hts-pilot-skills.zip) - one: `/skills/hts-pilot-api.zip`, `/skills/hts-pilot-webhooks.zip`, `/skills/hts-pilot-batch.zip` - the list, with every file of each skill and the SHA-256 of each archive: [/skills/index.json](/skills/index.json) - a single file to read: `/skills/hts-pilot-api/SKILL.md`, and the same for the other two ### Installing a skill Unpack the archive into your agent's skills folder, one folder per skill, so that the agent finds `hts-pilot-api/SKILL.md`. Where that folder is depends on the tool: its documentation says where it looks for skills, usually a folder in the project or in your home directory. A tool without skills can still use one: give it the `SKILL.md` as instructions, or point it at [/skill.md](/skill.md). ```bash curl -O https://docs.htspilot.com/skills/hts-pilot-skills.zip unzip hts-pilot-skills.zip -d path/to/your/agent/skills ``` ### What is in a skill ``` hts-pilot-api/ SKILL.md when to use it, the steps, the pitfalls, how to verify scripts/hts_pilot.py a client, Python standard library only scripts/hts_pilot.mjs the same for Node.js 18 or newer, no dependency references/*.md copies of the pages the skill relies on ``` The scripts read the API key from the environment variable `HTS_PILOT_API_KEY` (the webhook receiver reads its secret from `HTS_PILOT_WEBHOOK_SECRET`) and never print it. They are run in our tests against the API. Each `SKILL.md` names the addresses of the current pages, so an agent can check the skill against the documentation of today; the copies under `references/` are from the day the archive was built. ### Keeping the key out of the conversation Set the key in the environment of the process the agent runs, not in the prompt, and not in a file the agent commits. A key pasted into a conversation should be revoked and replaced ([Authentication](/authentication.md#keeping-a-key-safe)). Give the agent a key with the lowest role that works. ## What a result is Whatever calls the API, a result is a suggestion for reference, not a classification decision by a customs authority. An agent that shows a result to a person should say so, and should send lookups that need review or more information to a person. # Changelog > Dated versions of the HTS Pilot API and what changed in each. Each entry is a dated record of the published contract: the OpenAPI document as it was on that day. The service always runs the latest version ([Versioning and changes](/versioning.md)). The list under an entry is generated from the difference between its document and the one before. ## 2026-10-03 **The history starts here** The first recorded version of the API, with three import markets (`US`, `EU`, `VN`): 55 operations for lookups, review, batches, the SKU catalog, tariff data, usage, webhooks, reports and system status. Every answer carries `X-Request-ID` and every error a `code` ([Request identifiers](/request-identifiers.md), [Errors](/errors.md#error-codes)). The history starts here; earlier changes were not recorded. One of them is described in [Languages](/languages.md): the questions, the missing information and the legal findings of a lookup follow the language of the lookup, set with `language` on a classify request. The history starts here: there is no earlier version to compare with.