API Documentation

Integrate Codici HS code lookups into your systems using our REST API.

Base URL

https://beta.api.codi.ci

Authentication

Include your API key in every request via the api-key header:

Authorization: api-key YOUR_API_KEY

Or as a query parameter: ?api_key=YOUR_API_KEY

Manage API keys in your account.

Endpoints

GET /v1/lookup

Look up an HS code for a single product description.

ParameterRequiredDescription
descriptionrequiredProduct description in any language
regionoptionalCustoms region (default: global). Options: global, usa, european_union, great_britain, canada, china, japan, australia, saudi_arabia, faroe_islands
languageoptionalResponse language (default: en). Options: en, nl, fr, et, fo
GET https://beta.api.codi.ci/v1/lookup?description=MacBook+Pro+16&region=usa

POST /v1/lookup-batch

Classify up to 100 product descriptions in a single call. The response is an array of result objects in the same order as the descriptions you sent.

Billing counts lookups, not API calls. Each description in the batch is one lookup against your monthly allowance, so a call containing 100 descriptions uses 100 lookups — batching saves round trips and time, not cost.

curl -X POST https://beta.api.codi.ci/v1/lookup-batch \ -H "api-key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"descriptions":["MacBook Pro","Organic champagne"],"region":"usa"}'

GET /v1/code

Describe a tariff code you already have: its wording, its place in the nomenclature and any control-list flags. Use it when a person keys in or picks a code themselves and you want it shown the way a classified one is. Nothing is classified, so these requests do not count against your plan.

ParameterRequiredDescription
coderequiredAt least four digits, with or without punctuation
regionoptionalCustoms region (default: global)
languageoptionalLanguage of the wording (default: en)
confidenceoptionalHow sure the choice of code was, 0 to 1 (so 0.9 means 90%). Flags carry this number; without it they carry 1.
GET https://beta.api.codi.ci/v1/code?code=8471.30.00&region=european_union&language=nl

The response is not signed. known is true when the code is a line of the region's nomenclature; otherwise description is null and no flags are checked. It also carries classification_breakdown, restriction_flags, restriction_check, notices and nomenclature, in the same shapes as a lookup result, and quota.

FieldMeaning
quota.earned_24hDescriptions earned by the lookups of the last 24 hours: the number of lookups times the number earned per lookup.
quota.used_24hCode requests made in that window, including this one.
quota.remaining_24hWhat is left: earned minus used.
quota.per_minuteThe per-minute request limit.

quota is only present when the request succeeds.

Access is earned by classifying. A key can use this endpoint only if it has classified a description in the last 24 hours, and every successful lookup in that window earns a number of descriptions (20 at the moment). A per-minute limit applies on top (30 at the moment). Every accepted request is counted, whatever its parameters, so requests that fail validation do not retry for free. quota shows what is left. Without a recent lookup you get 403 lookup_required; past the earned number, 429 code_quota.

GET /v1/balance

Where you stand this month: your plan, how many lookups it includes, how many you have used, how many remain, and any extras accrued beyond the allowance.

GET https://beta.api.codi.ci/v1/balance

GET /v1/history

Your recent API usage records: when each lookup happened, from which key and which IP. This endpoint returns metadata only. The lookups themselves are recorded, and become visible in the portal once a company administrator switches on Keep requests.

GET https://beta.api.codi.ci/v1/history

Response format

Every lookup comes back in a signed envelope. The data object holds the request echo, an anonymous requestor reference, the classification result and our platform details. hmac is an HMAC-SHA256 signature over data, so a saved result can be proven to have come from us, unaltered — paste it into the verification page to check.

result_not_used sits deliberately outside data and is therefore not covered by the signature. It carries fields we have not yet curated into result and will shrink over time, so treat it as transitional.

Field shapes do not change with the region you request. confidence and each alternative's likelihood are fractions between 0 and 1 (so 0.9 means 90% confident).

Codes and notation

hs_code is the six-digit WCO Harmonized System code, written xxxx.xx everywhere. hts_code is the full national code, written the way that authority writes it:

RegionCodeNotationExample
European UnionCombined Nomenclature (8) / TARIC (10)digits only88040000
Great BritainUK commodity code (up to 10)digits only8804000000
United StatesHTSUS (10)xxxx.xx.xx.xx8211.92.90.30
Other regionsnational scheduledotted pairs3901.20.00.99.999

Compare codes on their digits, not their punctuation — strip everything that isn't a digit and the values are directly comparable across regions.

Verifying the signature yourself

You do not need to keep our exact JSON. To reproduce the signature: take the data object, sort every key alphabetically at every level, trim leading and trailing whitespace from every string value, serialise it compactly, and compute HMAC-SHA256 over that with your shared secret. Key order, indentation and stray whitespace therefore make no difference — only the field values do. result_not_used sits outside data and is deliberately not signed.

{ "data": { "request": { "description": "MacBook Pro 16", "region": "usa", "language": "en", "requested_at": "2026-07-27T09:14:02+00:00" }, "requestor": { "reference": "9BJ9FDRT" }, "result": { "hts_code": "8471.30.01.00", "hs_code": "8471.30", "declaration": { "status": "declarable", "code": "8471300100", "form": "taric10", "classified_as": "8471300100", "as_at": "2026-07-27", "options": [], "options_truncated": false, "note": "A complete code: nothing more specific sits under it, so it can be entered on a declaration." }, "confidence": 0.9, "characteristics": { "essential_character": "Portable automatic data processing machine", "material": "Mixed materials", "is_physical_item": true }, "classification_breakdown": [ { "depth": 0, "code": "84", "label": "Chapter", "text": "Nuclear reactors, boilers, machinery..." }, { "depth": 1, "code": "8471", "label": "Heading", "text": "Automatic data processing machines..." }, { "depth": 4, "code": "8471.30.01.00", "label": "Full classification (10-digit)", "text": "Portable automatic data processing machines..." } ], "reasoning": "The item is an automatic data processing machine...", "reasoning_detailed": "Main category: ...\n\nKey factors:\n• ...\n\nAssumptions:\n• ...", "alternatives": [ { "hts_code": "8471.41.0150", "hs_code": "8471.41", "likelihood": 0.05, "reasoning": "..." } ], "restriction_flags": [], "restriction_check": { "status": "completed", "lists_checked": [] }, "notices": ["Absence of a flag is not clearance. Check anything you export against the applicable control lists."], "nomenclature": { "source": "codici", "region": "european_union", "as_at": "2026-10-10", "edition": { "name": "CN 2026", "type": "annual", "valid_from": "2026-01-01", "valid_to": null, "data_version": null }, "extension": { "name": "TARIC", "type": "rolling", "data_version": "2026-10-09 19:17", ... }, "shared_layer": "HS 2026" } }, "platform": { "name": "Codici B.V.", "url": "https://beta.portal.codi.ci", "version": "1.2", "verify_url": "https://beta.portal.codi.ci/?page=verify" } }, "result_not_used": { "initial_classification": "8471.30", "hs_description": "Laptop computer, Apple MacBook Pro", "hs_description_localized": null, "chapter": "Nuclear reactors, boilers, machinery...", "additional_flags": ["FD1"], "description_improvement_suggestion": null, "suggested_description": null, "enhancement_attempted": false, "algorithm_version": 75, "processed_at": "2026-07-27T09:14:01.695Z", "cost": 0 }, "hmac": "3b64eeba3ff1e1b485a015bc1ba510a482c47d8da8bae34958cdad1fcd697583" }

cost is what this one lookup added on top of your fixed monthly fee: 0 while you are inside your allowance, your plan's per-extra-lookup price once past it. /v1/lookup-batch returns an array of these objects, one per description.

Which nomenclature a result came from

Every result records the edition of the tariff nomenclature its code and descriptions were taken from. It is inside result, so it is covered by the signature: if you are asked months later which version a classification was based on, the answer is in the result you kept, and the verification page uses it to tell you whether the code has changed since.

FieldMeaning
nomenclature.sourcecodici when the code and its levels came from our own copy of the schedule, classification_provider when they came from our upstream classifier (regions we do not hold yet), none when no code was returned, unavailable if the lookup could not be made.
nomenclature.as_atThe date the nomenclature was read for: the day the result was issued.
nomenclature.editionThe edition in force on that date. type is annual (a numbered edition, such as CN 2026) or rolling (a schedule that changes during the year, such as the UK and US ones). data_version identifies the loaded version of a rolling edition.
nomenclature.extensionA rolling edition that adds detail to an annual one: for the European Union, TARIC on top of the Combined Nomenclature. null when there is none.
nomenclature.shared_layerThe international (WCO) edition used to fill levels a national schedule does not publish.

Declaration codes

For the European Union, Great Britain and the United States, hts_code is the code as you would enter it on a customs declaration: a complete 10-digit code (written with dots for the US, without separators for the EU and Great Britain). The declaration block says whether the classified code is complete and, when it is not, what to choose from. Other regions are not checked and declaration.status is unchecked.

A classified code is not always one you can declare: the tariff may divide it further (a CN code split into several TARIC lines), and we never pick a line for you, because that depends on the goods. The check is made against the tariff as it stood on declaration.as_at, the day of the lookup, and is part of the signed result.

{ "hts_code": "0102291000", "declaration": { "status": "needs_subdivision", "code": null, "form": null, "classified_as": "0102291000", "as_at": "2026-10-11", "options": [ { "code": "0102291010", "description": "...", "declarable": true } ], "options_truncated": false, "note": "The classification is right but incomplete for a declaration: ..." } }
FieldMeaning
hts_codeWhen declaration.status is declarable, the complete 10-digit code to enter on the declaration. Otherwise the code as classified, which you should not enter as it stands.
declaration.statusdeclarable: nothing more specific sits under the code. needs_subdivision: the code is a real branch of the tariff but is divided further; choose from options. not_found: not a line of the tariff on that date. unchecked: this region is not checked, or the check could not be made.
declaration.codeThe complete 10-digit declaration code, or null when the status is not declarable.
declaration.formHow the tariff holds the code: taric10 (a 10-digit TARIC line), cn8 (an 8-digit CN code with nothing under it, declared as the 8 digits plus 00) or stored (kept at another length, such as a 6-digit UK line). null when not declarable.
declaration.classified_asThe digits of the code as classified, before any check.
declaration.as_atThe date the tariff was checked on.
declaration.optionsWhen the status is needs_subdivision: the lines directly beneath the code, each with its code, description (in the requested language where we have it) and whether it is itself declarable or divides again. Empty otherwise.
declaration.options_truncatedtrue when more lines exist than are listed.
alternativesEach alternative is matched to a line of the tariff. A code that is a real line is shown in its complete 10-digit form when it can be declared; one that is not a line is cut back to its HS subheading (six digits), as a branch to consider, never as a code to declare.

Restriction flags

For the European Union we currently check these lists: dual-use goods (Regulation (EU) 2021/821, Annex I), drug precursors (Regulations (EC) 111/2005 and 273/2004), the export rules for hazardous chemicals (Regulation (EU) 649/2012, Annexes I and V), waste shipments (Regulation (EC) 1013/2006 and its replacement 2024/1157), import of cultural goods (Regulation (EU) 2019/880), CITES (endangered species) and ozone-depleting substances and F-gases. The last two, and waste shipments, rest on coarse or dated correlation tables and say so in each flag. Other regions are not checked yet, and restriction_check.lists_checked says so.

Every result says whether the classified code may fall under a control list, so you can go back to your supplier for the datasheet or a written declaration before you export. A flag is a prompt to check, never a determination. And the absence of a flag is not clearance: the lists we check are indicative, a controlled item can be declared under an unexpected code, and whether an export is controlled often depends on the destination, end-user and end-use, which a lookup does not know about.

The structure is the same for every list, so lists we add later appear in the same fields. These fields sit inside data.result, so they are part of the signature. Fields are only ever added, never removed or renamed; platform.version changes when one is added.

{ "restriction_flags": [ { "list": "EU_DUAL_USE", "list_name": "EU dual-use control list (Annex I)", "status": "possible_match", "confidence": 0.93, "specificity": "narrow", "match": "exact", "partial_coverage": false, "matched_codes": ["26121010"], "reference": "Regulation (EU) 2021/821, Annex I", "linked_entries": 1, "main_entries": 1, "candidates_truncated": false, "candidates": [ { "entry": "0C001", "label": "\"Natural uranium\" or ...", "sub_entries": [], "source_refs": ["DU017"] } ], "source_version": { "list_edition": "September 2024", "mapping_edition": "1 January 2026", "nomenclature_edition": "CN 2026" }, "note": "Indicative only. ..." } ], "restriction_check": { "status": "completed", "lists_checked": [ { "list": "EU_DUAL_USE", "name": "EU dual-use control list (Annex I)", "mapping_edition": "1 January 2026" } ] }, "notices": ["Absence of a flag is not clearance. ...", "EU dual-use: controls can also depend on destination, end-user and end-use ..."] }
FieldMeaning
restriction_flagsOne object per control list the classified code may fall under. An empty array means no flag, not that the item is clear. Check restriction_check to see which lists were looked at.
restriction_check.statuscompleted, or unavailable if the check could not run for this lookup. When it is unavailable the empty restriction_flags means nothing at all.
restriction_check.lists_checkedThe lists this result was checked against, with the edition of the code mapping used. An empty array means we check no list for the region you asked about.
noticesStanding statements about what the check does not cover. Always show them with the flags.
list, list_nameIdentifier and name of the control list the flag comes from.
statusAlways possible_match: the code is linked to entries on the list, so check. It is never a statement that the item is controlled.
confidenceThe classification's own confidence. A flag follows directly from the code, so it is exactly as reliable as the classification and carries the same number. It does not say how likely the item is to be controlled.
specificityHow much a match tells you: narrow (the code links to 1 entry and the list does not mark it "ex"), limited (2 to 4 entries, or 1 entry on a code marked "ex") or broad (5 or more). The more entries share one code, and the more goods under the code the list leaves out, the less a match says about your item.
matchexact: the code itself is listed. broader_code: a broader listed code includes yours. narrower_codes: your classification stops higher up and codes beneath it are listed, so a more specific classification would narrow this down.
partial_coveragetrue when the list itself marks every matching code "ex": it applies to only some of the goods under the code, so most goods with this code are likely not affected. false means the list makes no such statement, not that the code is covered in full.
matched_codesThe listed codes that matched.
referenceThe legal text the list comes from.
linked_entries, main_entriesHow many distinct list entries the code links to, counting sub-entries, and how many main entries those fall under.
candidatesThe main entries to check first: entry, its label as worded in the list (null if the entry is newer than the list edition we hold), the sub_entries that apply, and the source_refs that carry the link in the official tariff. These are leads, not findings.
candidates_truncatedtrue when the code links to more main entries than are listed here.
source_versionWhich edition of the list, of the code mapping and of the tariff nomenclature this flag was produced from. Lists are revised at least yearly.
noteWhat a match on this list means and what to do about it.
scope_noticeWhat the check on this list does not cover, as one sentence. It is the same wording that appears in notices for this list, so an interface can show it next to the flag instead of every list's notice. null when the list has none.

Error responses

HTTP statusCodeMeaning
400missing_param / invalid_param / invalid_json / too_manyMissing or invalid parameters (too_many = more than 100 descriptions in one batch)
401missing_key / invalid_key / key_revokedMissing, unknown or revoked API key
402no_planNo plan is active on the account, so lookups are unavailable — contact us to get set up
403lookup_required/v1/code only: this key has not classified anything in the last 24 hours
429rate_limit / code_quotaRate limit exceeded, or (for /v1/code) all code descriptions earned in the last 24 hours are used
500upstream_errorInternal error (upstream provider or system)

Going over your included lookups is never an error — extra lookups are simply charged at your plan's per-lookup rate and appear on that month's invoice.

Not set up yet? Talk to us and we'll put you on the right plan and get your API keys issued.