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

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