Build
Webhooks
When asynchronous work finishes, Tarifflo POSTs the result to your organization's webhook endpoint.
Where results go
Set one endpoint per environment in Developers → Webhooks. Events from work a tflo_test_ key started go to the test endpoint, and events from a tflo_live_ key will go to the live endpoint once live keys are issued. To send one request's events elsewhere, set the Tarifflo-Webhook-Url header, or the webhookUrl body field where an endpoint takes one. The URL must be public HTTPS. Private and internal addresses are refused.
Without an endpoint, poll the job's GET endpoint, and every poll counts as a request. CBP answers have no GET: they are sent only by webhook. A request that sends to CBP with no endpoint for its key's environment is refused with 412.
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/v2/entries |
cbp_query.answered | CBP answered a query you sent (entry summary, importer, bond, exchange rate) |
cbp_query.failed | CBP rejected a query you sent, or never answered it |
webhook.test | Sent when you save or test an endpoint |
Payload
Every delivery has the same envelope. data is the same object the job's GET endpoint returns once it is complete. A CBP answer (entry.disposition, cbp_query.answered, cbp_query.failed) to a request an API key sent also carries livemode: false for a test key, which sends to CBP certification, and true for a live key (production CBP, not issued yet).
Saving a new endpoint (or pressing Send test) in the console sends it one signed webhook.test event whose data is { "environment": "test", "message": "Tarifflo webhook test" }. Answer it with a 2xx within 10 seconds; the console shows whether it did. It is not retried.
{
"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.