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).
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):
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.
| 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): ignore the ones you do not handle.
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=bce26408a75226f8d6757025791d249ed1010ad8293979eed52860c62bd97fb7
User-Agent: HTS-Pilot-webhook/1.0
{"event":"lookup.completed","created_at":"2026-10-03T16:07:51.375642+00:00","data":{"lookup_id":"bcb6a35ae2694828b191f78ed704a9a9","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.
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:
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):
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
401and do nothing else with it. - The signature covers the body only. It carries no timestamp: use
idto recognise a delivery you have already processed.
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
3xxanswer 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
idto 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.
The URL is checked when the webhook is created or changed, and again before every attempt:
httpsonly;- 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.
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.