REFERENCE

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/webhooks

Verify 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.ping

Customs events

  • classification_batch.completed

    A 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_url

  • classification_batch.failed

    The 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_url

  • customs_nomenclature.changed

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