API Reference · v1
Tarri API
Send a product's name, listing copy and photo; get back the 10-digit HTS line, the full landed-duty stack as CBP collects it this month, the CBP rulings behind the pick, and a confidence score that says when to trust it. One REST call, or a batch of 200.
Introduction
The API is JSON over HTTPS. Two endpoints are metered (POST /classify and POST /classify/batch: one credit per product). Everything else is free and only rate-limited: HTS line lookups with the current program rates for an origin, duty math for a code you already hold, CBP CROSS rulings, and your own usage.
https://app.tarri.ai/api/v1Authorization: Bearer <key>application/jsonThe duty stack it returns is the one in force since 24 July 2026: MFN, China-list Section 301 (Lists 1–4A and the four-year review, with the 178 live exclusions flagged), Section 232 at 25% (Annex I-B) or 50% (Annex I-A) of full FOB on steel, aluminum and copper articles, the forced-labor Section 301 tier by origin (12.5% or 10%, skipped on 232 articles and Annex II exemptions), and MPF and HMF. Section 122 expired and defaults to 0.
Authentication & keys
Every request except GET /plans and GET /openapi.json carries your secret key as a bearer token:
Authorization: Bearer tarri_sk_live_…- Create keys in the app under Settings → API. A workspace holds up to 10 live keys; name one per integration ("Shopify sync", "PIM import") so you can rotate them independently.
- Shown once. Only a SHA-256 hash is stored. Copy the key when it appears; if it is lost, revoke it and create another.
- Revoke a key and requests with it fail with
401 invalid_api_keyimmediately. Revoked keys stay listed for your records. - Server-side only. Never ship a key in a browser, a mobile app, or a public repository. Store it as
TARRI_API_KEY.
Plans & credits
One credit is one classification, with or without a photo. Lookups, duty math and rulings never cost credits. Unused credits roll over up to 3× your monthly allowance, so an onboarding month can draw on the quiet months around it.
| Plan | Price | Credits / month | Overage | Fits |
|---|---|---|---|---|
| Starter | $49/mo | 500 | $0.12 per classification | Small importers and marketplace sellers classifying a catalog and its monthly additions. |
| Growth | $199/mo | 2,000 | $0.10 per classification | Brands and sourcing platforms with thousands of SKUs and steady new-product flow. |
| Scale | $699/mo | 8,000 | $0.09 per classification | Platforms, marketplaces, 3PLs and brokers classifying for many clients. |
| Enterprise | Custom | 50,000+ | Negotiated | Volume contracts from 50,000 classifications a month with an SLA and invoicing. |
When the balance is spent, plans with overage keep answering at the per-credit price, capped by the spend cap you set in Settings → API (a cap of $0 stops at the allowance). Overage is invoiced with your next cycle. Every classify response and GET /usage report credits_remaining, so your integration can warn before it hits 402.
The app's Team plan includes 500 classifications a month through the API without an API plan (Enterprise app plans are not metered); add an API plan for rollover, overage, batch and webhooks.
Quickstart
Set TARRI_API_KEY in your environment and classify one product:
curl -X POST https://app.tarri.ai/api/v1/classify \
-H "Authorization: Bearer $TARRI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sku-48213" \
-d '{
"name": "Hybrid Steel Fry Pan",
"description": "Stainless steel fry pan with ceramic nonstick interior, 10 inch",
"origin": "CN",
"fob": 55650,
"units": 5000
}'const res = await fetch("https://app.tarri.ai/api/v1/classify", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.TARRI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "sku-48213",
},
body: JSON.stringify({
name: "Hybrid Steel Fry Pan",
description: "Stainless steel fry pan with ceramic nonstick interior, 10 inch",
origin: "CN",
fob: 55650,
units: 5000,
}),
});
if (res.status === 429) { /* back off for res.headers.get("Retry-After") seconds */ }
if (!res.ok) throw new Error((await res.json()).detail.message);
const result = await res.json();
console.log(result.best.htsNumber, result.best.confidence, result.duty.totalKnownDuty);import os, requests
res = requests.post(
"https://app.tarri.ai/api/v1/classify",
headers={
"Authorization": f"Bearer {os.environ['TARRI_API_KEY']}",
"Idempotency-Key": "sku-48213",
},
json={
"name": "Hybrid Steel Fry Pan",
"description": "Stainless steel fry pan with ceramic nonstick interior, 10 inch",
"origin": "CN",
"fob": 55650,
"units": 5000,
},
timeout=60,
)
res.raise_for_status()
result = res.json()
print(result["best"]["htsNumber"], result["best"]["confidence"], result["duty"]["totalKnownDuty"])Products the deterministic rules recognize answer in about 3 seconds; products that need the vision and reranking models take 10–30 seconds. Set client timeouts to 60 seconds, or use the batch endpoint for catalogs.
Idempotency & retries
Network calls fail and jobs get re-run. Send Idempotency-Key on POST /classify (your SKU id works well) and Tarri returns the original response for 24 hours without consuming another credit, marked with the header Idempotent-Replayed: true. Reusing a key with a different body returns 409 idempotency_key_reused. The key is claimed before any credit moves, so two concurrent requests with the same key never both classify: the second receives 409 idempotency_in_progress until the first finishes.
Safe retry rule: on 429, 502 and 503, retry with exponential backoff and the same Idempotency-Key. 502 classification_failed never consumes a credit, and a failed request releases its Idempotency-Key so the retry goes through.
Send X-Request-Id with your own correlation id; it is echoed on every response (and generated when absent). Quote it when you write to support.
Rate limits
The same limits apply to every plan, per key: 60 single classifications per minute, 6 batch requests per minute (up to 200 items each, worked by our queue at its own pace), and 600 free requests per minute. A 429 rate_limited carries Retry-After in seconds. For a catalog, batch is the high-throughput path: one request queues up to 200 products.
Errors
Every error shares one shape. error is the machine-readable code; detail.message is safe to surface to an operator; detail.doc_url links here.
{
"error": "spend_cap_reached",
"credits_remaining": 0,
"detail": {
"code": "spend_cap_reached",
"message": "Your overage spend cap for this period is reached. Raise it in Settings → API or wait for the next cycle.",
"doc_url": "https://tarri.ai/docs/errors#spend_cap_reached"
},
"request_id": "6c1f…"
}| Status | Code | Meaning |
|---|---|---|
400 | invalid_hts_code / invalid_batch_item / validation | The body or path failed validation. Batch: the failing index is included and nothing was queued. |
400 | webhook_secret_required | The batch named a webhook_url but the workspace has no signing secret yet. Generate one in Settings → API. |
401 | missing_api_key / invalid_api_key | No bearer header, or the key is unknown or revoked. |
402 | api_plan_required | The workspace has no active API plan. |
402 | credits_exhausted | No credits left and the plan has no overage. |
402 | spend_cap_reached | The overage spend cap for this period is reached; raise it in Settings → API. |
402 | payment_past_due | Included credits are spent and the last invoice is unpaid. Granted credits still work; overage resumes once the payment method is updated. |
404 | hts_not_found / ruling_not_found / batch_not_found | Nothing matches. |
409 | idempotency_key_reused | The same Idempotency-Key arrived with a different body. |
409 | idempotency_in_progress | The original request with this Idempotency-Key is still running. Retry in a few seconds to receive its result. |
429 | rate_limited | Per-key limit exceeded. Honor Retry-After. |
502 | classification_failed | The engine failed. The credit was not consumed; retry with backoff. |
503 | database_unavailable / batch_unavailable | Temporary outage; retry with backoff. A batch that could not be queued consumed no credits. |
Versioning
The path carries the major version (/api/v1). Additive changes (new fields, new endpoints, new duty line kinds) ship without notice; read objects by field name and ignore what you do not know. Breaking changes get a new major version with at least 90 days of overlap. Tariff data changes (new HTS revisions, new Chapter 99 programs) are not API changes; they flow through the same fields.
POST /classify
Classify one product. Consumes one credit. Returns the pick, ranked alternates, the duty breakdown when fob is sent, and supporting rulings.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Conditional | Short product name, e.g. Hybrid Steel Fry Pan. At least one of name, description, or imageUrl is required. |
description | string | Conditional | What it is, what it is made of, how it is used. Material words matter: "enameled cast iron" and "stainless" land on different lines. |
imageUrl | string | Conditional | A data:image/…;base64, URL (recommended, up to ~10 MB) or an https URL on an allow-listed host. A vision model describes the photo and the description confirms material and form. |
origin | string | Optional | ISO 3166-1 alpha-2 country of origin. Defaults to CN. Drives China-list Section 301 (CN/HK only), Section 232, and the forced-labor Section 301 tier for the 60 investigated economies. |
fob | number | Optional | Customs value in USD. Needed for the dollar duty breakdown and for value-banded HTS lines ("valued not over $3 each"). |
units | number | Optional | Pieces in the shipment or set. With fob, gives the per-unit value that picks value bands. |
netWeightKg | number | Optional | Net weight in kilograms, for per-kilogram specific rates. |
section232Applies | boolean | Optional | Force Section 232 on or off. Omit to follow the HTS row: Annex I-A pays 50% and Annex I-B 25% of full FOB. |
oceanShipment | boolean | Optional | Adds the Harbor Maintenance Fee. Defaults to true. |
settings | object | Optional | Override engine rates: section232PrimaryRate, section232DerivativeRate, forcedLaborRate, forcedLaborReducedRate, section122Rate, mpfRate, mpfMin, mpfMax, hmfRate. |
fob and units too; glassware and several other headings split on per-unit value, and without it Tarri assumes the lowest band at reduced confidence.The response object
| Field | Type | Description |
|---|---|---|
best | Candidate | null | The pick, or null when Tarri abstains. |
candidates | Candidate[] | Ranked; includes best. Alternates that share the pick's heading are material or value-band variants worth a look; alternates at 0.5 are retrieval filler. |
abstained | boolean | true when no candidate cleared the confidence threshold (0.62). Review candidates; do not auto-accept. |
abstainReason | string | null | Why Tarri abstained. |
imageDescription | string | null | What the vision model saw, when you sent a photo. |
duty | Duty | null | The landed-duty breakdown for best. null without fob. |
rulings | Ruling[] | CBP CROSS rulings that support the pick. |
perUnitValue | number | null | fob ÷ units when both were sent. |
pipeline | object | Diagnostics: directCodeMatch, headings[], embeddingUsed, aiRerankUsed, rulingsUsed. |
credits | object | { consumed, overage, remaining } for this call. |
request_id | string | Echo of X-Request-Id, or a generated id. Quote it to support. |
Example (abridged):
{
"best": {
"htsNumber": "7323.93.00.45",
"htsDigits": "7323930045",
"item": "Table, kitchen or other household articles … > Of stainless steel > Cooking ware: > Other",
"confidence": 0.86,
"rationale": "Deterministic named-cookware match: … (stainless steel is 7323.93).",
"generalDuty": "2%", "generalDutyRate": 2,
"section301Duty": "0.00%", "section301Rate": 0,
"section301ExclusionScope": "",
"section232Annex": "I-B", "section232Category": "steel-derivative",
"forcedLaborExempt": false
},
"candidates": [ /* best plus 7323.99.70.00 (carbon steel) and 7323.94.00.26 (enameled) */ ],
"abstained": false,
"duty": {
"fob": 55650,
"lines": [
{ "kind": "standard", "rate": 2, "amount": 1113, "basis": "FOB" },
{ "kind": "section301", "rate": 0, "amount": 0 },
{ "kind": "section232", "rate": 25, "amount": 13912.5, "basis": "Full FOB", "note": "Annex I-B (steel derivative), Proclamation 11032." },
{ "kind": "forcedLabor", "rate": 0, "amount": 0, "note": "Skipped: Section 232 articles are exempt (U.S. note 52(f))." },
{ "kind": "mpf", "rate": 0.3464, "amount": 192.772 },
{ "kind": "hmf", "rate": 0.125, "amount": 69.563 }
],
"totalKnownDuty": 15287.835,
"landedCostKnown": 70937.835,
"effectiveKnownRate": 0.2747,
"manualReview": false
},
"rulings": [ { "rulingNumber": "N326219", "subject": "…", "url": "https://rulings.cbp.gov/ruling/N326219" } ],
"credits": { "consumed": 1, "overage": false, "remaining": 499 },
"request_id": "6c1f…"
}Reading confidence
Treat 0.80 and up as ready to file. 0.62 to 0.79 is right at the heading with a suffix or fact to confirm; the rationale names it (unstated metal, assumed value band, a photo that disagrees with the title). Below 0.62 Tarri abstains: best is null, candidates holds the closest lines, and a person should choose. Retail sets with no single essential character (a picture-hanging kit, a hardware assortment) are the usual abstentions.
The candidate object
| Field | Type | Description |
|---|---|---|
htsNumber | string | Formatted 10-digit code, e.g. "7323.93.00.45". |
htsDigits | string | Digits only, e.g. "7323930045". |
item | string | Full HTS line description, heading to statistical suffix, joined by " > ". |
confidence | number | Calibrated, 0 to 1. 0.80 and up is high; 0.62 is the abstention threshold; 0.72 with a note means "confirm one fact" (unstated material, assumed value band). |
rationale | string | Why this line: the rule or the reranker's reasoning, with CBP ruling numbers when they apply. |
generalDuty / generalDutyRate | string / number | null | Column 1 MFN rate. null rate means specific or compound (needs review). |
section301Duty / section301Rate | string / number | null | China-list Section 301 (Lists 1–4A, four-year review). Applies to CN/HK origin only. This is not the forced-labor 12.5%. |
section301ExclusionScope | "" | "full" | "partial" | A live 9903.88.69/.70 exclusion covers the line (through 9 November 2026). full zeroes the list rate when claimed on entry; partial covers only goods matching the note description. |
section232Annex | "none" | "I-A" | "I-B" | I-A pays 50%, I-B 25% of full FOB. section232Category says steel, aluminum or copper. |
forcedLaborExempt | boolean | On Annex II Part A of the forced-labor action: no forced-labor duty for any origin. forcedLaborExemptScope flags conditional scopes (Ex, Aircraft, Pharma). |
The duty object
Present when you send a positive fob. Each line carries its percent rate and its dollar amount, so you can show the stack or just the total.
| Field | Type | Description |
|---|---|---|
lines | DutyLine[] | { kind, label, basis, rate, amount, reviewRequired, note?, missing? }. Kinds: standard, section301, section232 (only when the row is on an annex), forcedLabor (0 with a note on any 232 article), section122 (only when you set a non-zero rate), mpf, hmf. |
totalKnownDuty | number | USD sum of computable lines. |
landedCostKnown | number | FOB plus total known duty. |
effectiveKnownRate | number | Total known duty ÷ FOB as a fraction (0.275 = 27.5%). |
manualReview | boolean | A line needs a human: a specific or compound rate, or a net-of-MFN forced-labor tier with a specific MFN. |
missingInputs | string[] | What would let a line compute, e.g. ["quantity (units)"]. |
POST /classify/batch
Up to 200 products in one request. Every item is validated first (a bad item rejects the whole batch with its index), credits are consumed up front, and the batch is queued. When it settles, credits for failed items come back, and any overage is billed only for the items that succeeded. Poll GET /batches/{id} or pass webhook_url to be called when it completes. Items run in parallel on our side, so a 200-item catalog finishes in minutes, not hours.
curl -X POST https://app.tarri.ai/api/v1/classify/batch \
-H "Authorization: Bearer $TARRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://example.com/hooks/tarri",
"items": [
{ "id": "SKU-1", "name": "Copper Moscow Mule Mugs", "description": "16oz solid copper mugs", "origin": "CN", "fob": 8.2, "units": 1 },
{ "id": "SKU-2", "name": "Digital Meat Thermometer", "description": "Fold-out probe, bright display", "origin": "CN" }
]
}'
# → 202 { "id": "…", "status": "queued", "item_count": 2, "poll_url": "/api/v1/batches/…" }
curl https://app.tarri.ai/api/v1/batches/$BATCH_ID -H "Authorization: Bearer $TARRI_API_KEY"Each item accepts the same fields as /classify plus an optional id that is echoed back so you can join results to your rows. Batch status is queued, processing, completed or failed (every item failed); per-item status is queued, completed or failed with an error string.
GET /hts/{code}
Free. Look up an HTS line by 4 to 10 digits (dots optional) and get its program rates for an origin: section301EffectiveRate, section232Rate and annex, forcedLaborRate, section122Rate. This is how you re-price a catalog after a tariff change without re-classifying it: codes do not change when rates do.
curl "https://app.tarri.ai/api/v1/hts/7323.93.00.45?origin=CN" -H "Authorization: Bearer $TARRI_API_KEY"
# → { "htsNumber": "7323.93.00.45", "generalDutyRate": 2, "section301Rate": 0,
# "section232Annex": "I-B", "section232Rate": 25, "forcedLaborRate": 0, "section122Rate": 0, … }POST /duty
Free. The duty breakdown for a code you already hold: { "htsDigits", "fob", "origin", "units", "netWeightKg", "section232Applies", "oceanShipment", "settings" }. Returns { line, duty } with the same duty object as classify.
GET /rulings
Free. CBP CROSS rulings by code (an HTS prefix) or free-text q, up to limit (default 24). GET /rulings/{number} returns one ruling with its text. Rulings are fact-specific precedent, useful when a broker asks "why this line".
GET /me · /usage · /plans
/me returns the workspace's plan, live keys and limits. /usage returns credits_remaining, the current period, overage so far and a 30-day daily series, which is what you want on an internal dashboard. /plans is public and returns the plan catalog above as JSON.
Webhooks
Add webhook_url to a batch request and Tarri POSTs { "type": "batch.completed", "batch_id", "url", "sent_at" } when the batch finishes. Requests carry Tarri-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" with the signing secret from Settings → API (generate it first; a batch naming a webhook_url without one is refused with webhook_secret_required). Verify the signature and reject timestamps older than five minutes:
import crypto from "node:crypto";
export function verifyTarriSignature(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Delivery is attempted once with a 10-second timeout. Your handler should fetch the batch by id rather than trust the payload, so a missed delivery costs nothing more than a poll.
Integration recipes
- Catalog onboarding. Send products in batches of 200 with the SKU as
idandIdempotency-Keyper item on retries. StorehtsDigits,confidence,rationaleand the top ruling number. Route anything under 0.80 to a review queue. - New products. Classify on creation, with the photo. Show the rationale and the rulings next to the code so the person approving it sees the reasoning.
- Tariff changes. Do not re-classify. Call
GET /hts/{code}?origin=for each stored code and rebuild landed cost from the returned rates; it is free. - Landed-cost quotes.
POST /dutywith the stored code, the quote's FOB and units, and the buyer's origin. The response lines map directly to a quote's line items. - Spend control. Set a spend cap, read
credits.remainingon every response, and alert your team at 10% left.
Interactive reference
The full OpenAPI 3.1 description with every schema, rendered below. Import the spec into Postman, Insomnia, or a client generator.
Support
Email support@tarri.ai with the request_id and we will look at the exact run. Enterprise plans (50,000+ classifications a month, SLA, invoicing) start with the same address.