Webhooks
Subscribe to document-change and customs events. We deliver HMAC-signed JSON over HTTPS and retry with exponential backoff (1m → 5m → 30m → 2h → 12h).
Create a subscription
name, url and events are required. The whsec_ secret is returned once at creation — store it somewhere your worker can read. Only a SHA-256 hash is persisted on our side.
curl -X POST -H "Authorization: Bearer ld_live_…" \
-H "Content-Type: application/json" \
-d '{ "name": "catalog-jobs", "url": "https://example.com/hook",
"events": ["classification_batch.completed", "classification_batch.failed"] }' \
https://api.legaldata.cleolabs.co/v2/webhooksVerify the signature
Every delivery carries an X-Cleo-Signature: sha256=<hex> header. The signing key is the hex SHA-256 of your secret, not the secret itself: hash the secret first, then compute HMAC-SHA256(hex(SHA-256(secret)), raw_body) and compare timing-safe. Anything else is a forgery — reject with 401.
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const key = createHash('sha256').update(secret).digest('hex');
const expected = 'sha256=' + createHmac('sha256', key).update(rawBody).digest('hex');
return header.length === expected.length &&
timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}Reject replays: X-Cleo-Signature-V2
Every delivery also carries X-Cleo-Timestamp (Unix seconds) and X-Cleo-Signature-V2: t=<timestamp>,v1=<hex>, where v1 is HMAC-SHA256(hex(SHA-256(secret)), "<t>.<raw_body>") with the same key as above. A retry is re-stamped with a fresh timestamp. Reject a timestamp more than 5 minutes from your clock, and store the event id for that window to drop repeats. X-Cleo-Signature is unchanged, so an existing verifier keeps working.
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
function verifyV2(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const key = createHash('sha256').update(secret).digest('hex');
const expected = createHmac('sha256', key).update(`${t}.${rawBody}`).digest('hex');
const got = String(parts.v1 ?? '');
return got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}Events
Pass one or more of these in events:
document.changeddocument.newdocument.refreshedsanction.addedrecall.addedregulation.amendedcustoms_code.changedcustoms_nomenclature.changedlegal.growth_digestclassification_batch.completedclassification_batch.failedtest.pingCustoms events
classification_batch.completedA customs classification batch finished. Delivered only to the subscriptions of the API key owner that submitted the job, never broadcast; filters are ignored.
data:
job_id,total_items,done_items,counts_by_status,completed_at,items_urlclassification_batch.failedThe last retry attempt of a classification batch failed. Sent at most once per job, to the same owner-only subscriptions.
data:
job_id,status: "failed",error,total_items,done_items,counts_by_status,failed_at,items_urlcustoms_nomenclature.changedA catalog import landed added, deleted or replaced codes for a tracked system. Delivers the same rows as GET /v2/customs/codes/changes, once per import run.
each change row:
id,country,system,from_version,to_version,code,change,replaced_by,one_to_one,effective_date,detected_at
See the REST reference for the full subscription / delivery schema.