Build
Webhooks
When an asynchronous job finishes, Tarifflo POSTs the result to the webhookUrl you passed with the request.
Where results go
There are no stored endpoints. Each request carries its own webhookUrl, and that job's result goes there. The URL must be public HTTPS; private and internal addresses are refused. If you omit it where it is optional, poll the job's GET endpoint instead.
Events
| Event | Sent when |
|---|---|
classification.completed | A classification request finished |
classification_batch.completed | A bulk classification batch finished |
tariff_rate.completed | A tariff-rate lookup finished |
pre_audit.completed | An audit intake run finished |
export_control.completed | An export-control (ECCN) determination finished |
origin.completed | A rules-of-origin determination finished |
entry.disposition | CBP answered an entry filed with POST /api/v1/entries |
Payload
Every delivery has the same envelope. data is the same object the job's GET endpoint returns once it is complete.
{
"event": "tariff_rate.completed",
"timestamp": "2026-10-23T14:23:45.123Z",
"data": { "results": [ ... ] }
}{
"efid": "0193c1f2-...",
"entryNumber": 1234567,
"status": "APPROVED",
"numActionItems": 0
}Headers
X-Tarifflo-Event names the event. X-Tarifflo-Signature carries a timestamp and an HMAC-SHA256 of <timestamp>.<raw body>, keyed with your organization's webhook secret. Find the secret in the dashboard under Developers → Webhooks.
Content-Type: application/json
X-Tarifflo-Event: tariff_rate.completed
X-Tarifflo-Signature: t=1761229425,sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdVerify the signature
Recompute the HMAC over the timestamp, a dot and the raw body, compare it in constant time, and reject timestamps older than five minutes so a captured delivery cannot be replayed.
import crypto from "node:crypto";
// rawBody must be the exact bytes received, not re-serialized JSON.
export function verifyTarifflo(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.sha256 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify_tarifflo(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
ts = parts.get("t", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
signed = ts.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("sha256", ""))Retries
Answer with any 2xx within 10 seconds. A timeout, a network error, a 5xx, a 408 or a 429 is retried; any other 4xx is treated as a refusal and not retried. Deliveries can arrive more than once, so make your handler idempotent.