Skip to the content

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

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

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

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.

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.