Classification API Documentation
Integrate HS code classification into your applications with the Import8 REST API. Classify products using image, text, or both.
https://import8.nl/api
Authentication
All API requests (except /health) require authentication via an API key. Generate API keys in your dashboard.
Pass your API key via the Authorization header using the Bearer scheme:
curl -H "Authorization: Bearer c8_your_api_key" \
https://import8.nl/api/v1/me
import requests
response = requests.get(
"https://import8.nl/api/v1/me",
headers={"Authorization": "Bearer c8_your_api_key"}
)
print(response.json())
const response = await fetch('https://import8.nl/api/v1/me', {
headers: { 'Authorization': 'Bearer c8_your_api_key' }
});
const data = await response.json();
Usage Tracking
All classification endpoints include a usage object in the response, so you can track your remaining credits without a separate API call.
"usage": {
"credits_remaining": 47,
"credits_days_left": 12,
"credits_run_out_date": "2026-11-09"
}
| Field | Type | Description |
|---|---|---|
| credits_remaining | integer | Number of credits remaining across all active balances |
| credits_days_left | integer | null | Expected number of days until the credits run out, based on the average usage of the past 28 days and the expiry dates of the credits. null if it cannot be calculated (too little usage to base it on, or an exempt organization). 0 when the credits have run out. |
| credits_run_out_date | string (Y-m-d) | null | Expected date (Y-m-d) on which the credits run out. null if it cannot be calculated. Also returned by GET /v1/usage. |
/v1/classify
Submit a product for HS code classification. The classification is processed asynchronously -- you receive a 202 Accepted response with a classification ID. Poll for results or configure a webhook.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| image_url | string | optional | URL of a product image (up to 5 images supported) |
| image_base64 | string | optional | Base64-encoded product image |
| description | string | optional | Product text description |
| origin_country | string | optional | 2-letter ISO country code of product origin |
| destination_country | string | optional | 2-letter ISO country code (default: NL) |
| goods_value | number | optional | Unit price of the goods (FOB). When provided, landed cost is calculated automatically after classification. |
| quantity | integer | optional | Number of units (default: 1) |
| shipping_cost | number | optional | Shipping / freight cost (default: 0) |
| insurance_cost | number | optional | Insurance cost (default: 0) |
| currency | string | optional | 3-letter currency code (default: EUR). Supported: EUR, USD, GBP, CNY. |
Note: At least one of image_url, image_base64, or description is required.
Landed cost: When goods_value is provided, the classification result will include a landed_cost object with CIF value, duty, anti-dumping, VAT, and total landed cost. This uses the same calculation as the standalone POST /v1/landed-cost endpoint.
Risk grading & documents: A completed result also includes risk_grading (regulatory intensity level L0–L6 for the HS code, plus CBAM / anti-dumping / sanctions flags) and documents (the required import documents per applicable regime). Both carry an available boolean: true alongside the data, or false with a reason (no_hs_code or error), so you can distinguish "no obligations" from "could not be computed". These objects are returned by this endpoint and by GET /v1/classifications/{id} — the webhook itself only signals that the result is ready.
Example Request
curl -X POST https://import8.nl/api/v1/classify \
-H "Authorization: Bearer c8_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"description": "Wireless bluetooth over-ear headphones with noise cancellation",
"image_url": "https://example.com/product.jpg",
"origin_country": "CN",
"destination_country": "NL",
"goods_value": 25.00,
"quantity": 100,
"shipping_cost": 150.00,
"insurance_cost": 12.50,
"currency": "EUR"
}'
import requests
response = requests.post(
"https://import8.nl/api/v1/classify",
headers={"Authorization": "Bearer c8_your_api_key"},
json={
"description": "Wireless bluetooth over-ear headphones with noise cancellation",
"image_url": "https://example.com/product.jpg",
"origin_country": "CN",
"destination_country": "NL",
"goods_value": 25.00,
"quantity": 100,
"shipping_cost": 150.00,
"insurance_cost": 12.50,
"currency": "EUR"
}
)
data = response.json()
classification_id = data["data"]["id"]
print(f"Classification queued: {classification_id}")
const response = await fetch('https://import8.nl/api/v1/classify', {
method: 'POST',
headers: {
'Authorization': 'Bearer c8_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
description: 'Wireless bluetooth over-ear headphones with noise cancellation',
image_url: 'https://example.com/product.jpg',
origin_country: 'CN',
destination_country: 'NL',
goods_value: 25.00,
quantity: 100,
shipping_cost: 150.00,
insurance_cost: 12.50,
currency: 'EUR',
}),
});
const { data } = await response.json();
console.log('Classification queued:', data.id);
Response 202 Accepted
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"message": "Classification queued. Poll GET /v1/classifications/{id} for results, or configure a webhook to receive notifications."
},
"usage": {
"credits_remaining": 47
}
}
/v1/classifications/{id}
Retrieve a classification result by its UUID. Poll this endpoint after submitting a classification request until the status changes from pending to completed. Final statuses are completed, failed and insufficient_information.
Example Request
curl https://import8.nl/api/v1/classifications/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer c8_your_api_key"
import requests
import time
classification_id = "550e8400-e29b-41d4-a716-446655440000"
# Poll until completed
while True:
response = requests.get(
f"https://import8.nl/api/v1/classifications/{classification_id}",
headers={"Authorization": "Bearer c8_your_api_key"}
)
data = response.json()["data"]
if data["status"] == "completed":
print(f"HS Code: {data['hs_code']['code_8']}")
print(f"Confidence: {data['confidence']}%")
break
elif data["status"] == "insufficient_information":
# Not classified, no credit used: ask the importer for a clearer description
print(data["insufficient"]["question"])
break
elif data["status"] == "failed":
print("Classification failed")
break
time.sleep(5)
const classificationId = '550e8400-e29b-41d4-a716-446655440000';
// Poll until completed
async function pollResult() {
const response = await fetch(
`https://import8.nl/api/v1/classifications/${classificationId}`,
{ headers: { 'Authorization': 'Bearer c8_your_api_key' } }
);
const { data } = await response.json();
if (data.status === 'completed') return data;
if (data.status === 'insufficient_information') return data; // see data.insufficient.question
if (data.status === 'failed') throw new Error('Classification failed');
await new Promise(r => setTimeout(r, 5000));
return pollResult();
}
Response 200 OK
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"source": "api",
"created_at": "2026-03-03T12:00:00+00:00",
"hs_code": {
"code_6": "8518.30",
"code_8": "8518.30.00",
"description": "Headphones and earphones",
"chapter": "85"
},
"confidence": 92,
"reasoning": "The product is wireless bluetooth headphones, which fall under HS heading 8518 for microphones, loudspeakers, and headphones.",
"decision_tree": [
{"level": "section", "code": "XVI", "description": "Machinery and mechanical appliances"},
{"level": "chapter", "code": "85", "description": "Electrical machinery and equipment"},
{"level": "heading", "code": "8518", "description": "Microphones, loudspeakers, headphones"}
],
"alternative_codes": [
{
"code": "8518.10.00",
"description": "Microphones",
"reasoning": "Would apply if the product includes a built-in microphone as primary function"
}
],
"product_summary": "Wireless bluetooth over-ear headphones with noise cancellation",
"reasoning_steps": [
{"sequence": 0, "kind": "product_profile", "status": "confirmed", "decided_by": "auditor", "decided_at": "2026-03-03T12:00:05+00:00"},
{"sequence": 1, "kind": "heading_candidates", "status": "confirmed", "decided_by": "auditor", "decided_at": "2026-03-03T12:00:08+00:00"},
{"sequence": 2, "kind": "heading", "status": "changed", "decided_by": "auditor", "decided_at": "2026-03-03T12:00:15+00:00", "motivation": "Heading 8518 names headphones directly (GRI 1)."},
{"sequence": 3, "kind": "outcome", "status": "confirmed", "decided_by": "auditor", "decided_at": "2026-03-03T12:00:30+00:00"}
],
"ai_usage": {
"input_tokens": 1234,
"output_tokens": 567,
"cost_usd": 0.00408
},
"completed_at": "2026-03-03T12:00:30+00:00",
"landed_cost": {
"cif_value": 2662.50,
"duty": {
"rate": "0.0%",
"amount": 0,
"type": "mfn"
},
"anti_dumping": {
"rate": "0.0%",
"amount": 0
},
"vat": {
"rate": "21.00%",
"amount": 559.13
},
"total_landed_cost": 3221.63,
"currency": "EUR"
},
"risk_assessment": {
"level": "medium",
"reasons": ["Electronics — CE marking, RoHS, WEEE registration and EMC compliance required"],
"should_warn": true,
"recommended_action": "Re-run this classification in Audit mode for a defensible decision trail, or consult a customs broker."
},
"risk_grading": {
"available": true,
"overall_level": 2,
"overall_label": "Conformity",
"trade8_tier": "free",
"cbam_applicable": false,
"anti_dumping_applicable": false,
"sanctions_applicable": false,
"dual_use_applicable": false,
"elements": [
{
"regime": "Electromagnetic Compatibility (EMC)",
"level": 2,
"label": "Conformity",
"applies": true,
"obligations": ["Affix CE marking and draw up an EU Declaration of Conformity"],
"legal_ref": "Directive 2014/30/EU",
"source_url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32014L0030",
"verified_at": "2026-05-20",
"national_authority": null
}
],
"rules_version": "v1.0.1"
},
"documents": {
"available": true,
"items": [
{
"name": "EU Declaration of Conformity (DoC)",
"status": "verplicht-EU",
"legal_ref": "Directive 2014/30/EU, Art. 14",
"source_url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32014L0030",
"source_quote": "The EU declaration of conformity shall state that the fulfilment of the essential requirements has been demonstrated.",
"national_authority": null,
"verified_at": "2026-05-20",
"regime_code": "EMC_2014_30",
"regime_name": "Electromagnetic Compatibility (EMC)"
}
]
}
},
"usage": {
"credits_remaining": 47
}
}
Response when the product cannot be identified 200 OK
When the description does not establish what the product is (for example test text, a file or column name, or an internal code), Import8 does not guess a code. The status is insufficient_information: no hs_code, no risk, and the credit is refunded. The insufficient object says what is missing (missing: key, label, question per item) and gives one question for the importer. A recognisable product with only a missing detail (for example the material) is still classified, with the most likely code, its assumptions and open questions. A clear text description is enough; a photo is optional. Submit a new classification with the improved description.
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"external_id": "SKU-633",
"status": "insufficient_information",
"source": "api",
"created_at": "2026-10-02T09:00:00+00:00",
"insufficient": {
"summary": "The description does not establish what the product is.",
"missing": [
{"key": "identity", "label": "What the product is", "question": "What kind of product is it?"},
{"key": "function", "label": "Use", "question": "What is it used for?"}
],
"question": "What is the product and what is it used for? A short text is enough."
}
},
"usage": {
"credits_remaining": 48
}
}
Open questions and the code funnel
When a choice below the heading rests on a fact the description does not give, the result carries it as an open question in decision_tree.questions: the code we chose (selected) and, per other answer, a branch with its code or code range, duty (duty_min_pct/duty_max_pct), the regimes it adds or removes and whether it differs. Every tree also counts the declarable codes that are still possible: codes_remaining (on the tree and on each question) is the number of declarable codes reachable through the open branches of the whole tree, 1 when nothing is open; codes_after (on each branch and on selected) is how many remain after that answer. A question nested under a branch (next) counts in the situation of that branch. Use the numbers to show how close the importer is to one code.
"decision_tree": {
"selected_code": "8460.19.10.00",
"codes_remaining": 3,
"questions": [
{
"id": 812,
"answerable": true,
"level": 2,
"fact": "numerical control",
"fact_keys": ["numerical control"],
"codes_remaining": 3,
"selected": {"code": "8460.19.00.00", "duty": "1.70%", "codes_after": 2},
"branches": [
{
"code": "8460.12.00.00",
"outcome": "code",
"code_reached": "8460.12.00.00",
"duty": "4.50%",
"duty_min_pct": 4.5,
"duty_max_pct": 4.5,
"regimes_added": ["EMC"],
"regimes_removed": [],
"differs": true,
"codes_after": 1,
"next": []
}
]
}
]
}
Answer a question with POST /v1/classifications/{id}/answers (question_id, fact_key, value, visible_in_input). The response carries result in the same shape as decision_tree above, with codes_remaining and codes_after recalculated; result.applied says whether the answer has been processed yet.
/v1/classifications
List all classifications for your organization, sorted by newest first. Results are paginated.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| per_page | integer | 20 | Number of results per page (max 100) |
Response 200 OK
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"product_summary": "Wireless bluetooth over-ear headphones",
"hs_code": "8518.30.00",
"confidence": 92,
"created_at": "2026-03-03T12:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total": 42,
"last_page": 3
},
"usage": {
"credits_remaining": 47
}
}
/v1/classify/batch
Submit up to 100 products for classification in a single request. Each item is processed individually and asynchronously. Returns a batch ID to track overall progress.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| items | array | required | Array of classification requests (max 100). Each item has the same fields as POST /v1/classify. |
Example Request
curl -X POST https://import8.nl/api/v1/classify/batch \
-H "Authorization: Bearer c8_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"description": "Wireless bluetooth headphones", "origin_country": "CN"},
{"description": "Stainless steel water bottle", "origin_country": "CN"},
{"description": "Cotton t-shirt, men, crew neck", "origin_country": "BD"}
]
}'
response = requests.post(
"https://import8.nl/api/v1/classify/batch",
headers={"Authorization": "Bearer c8_your_api_key"},
json={
"items": [
{"description": "Wireless bluetooth headphones", "origin_country": "CN"},
{"description": "Stainless steel water bottle", "origin_country": "CN"},
{"description": "Cotton t-shirt, men, crew neck", "origin_country": "BD"}
]
}
)
batch_id = response.json()["data"]["batch_id"]
const response = await fetch('https://import8.nl/api/v1/classify/batch', {
method: 'POST',
headers: {
'Authorization': 'Bearer c8_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: [
{ description: 'Wireless bluetooth headphones', origin_country: 'CN' },
{ description: 'Stainless steel water bottle', origin_country: 'CN' },
{ description: 'Cotton t-shirt, men, crew neck', origin_country: 'BD' },
]
}),
});
Response 202 Accepted
{
"success": true,
"data": {
"batch_id": "batch_abc123",
"total": 3,
"classification_ids": [
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002",
"550e8400-e29b-41d4-a716-446655440003"
]
},
"usage": {
"credits_remaining": 47
}
}
/v1/classify/batch/{batch_id}
Check the status of a batch classification request. Returns the status of each individual classification in the batch.
Response 200 OK
{
"success": true,
"data": {
"batch_id": "batch_abc123",
"total": 3,
"completed": 2,
"pending": 1,
"failed": 0,
"insufficient_information": 0,
"classifications": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"status": "completed",
"hs_code": "8518.30.00"
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"status": "completed",
"hs_code": "7323.93.00"
},
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"status": "pending",
"hs_code": null
}
]
},
"usage": {
"credits_remaining": 44
}
}
/v1/classifications/{id}/feedback
Submit feedback on a classification result. Confirm the classification was correct or provide the correct HS code. This helps improve the system over time.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | required | confirmed or corrected |
| corrected_code | string | if corrected | The correct HS code (required when status is corrected) |
Example: Confirm
curl -X POST https://import8.nl/api/v1/classifications/550e8400-.../feedback \
-H "Authorization: Bearer c8_your_api_key" \
-H "Content-Type: application/json" \
-d '{"status": "confirmed"}'
Example: Correct
curl -X POST https://import8.nl/api/v1/classifications/550e8400-.../feedback \
-H "Authorization: Bearer c8_your_api_key" \
-H "Content-Type: application/json" \
-d '{"status": "corrected", "corrected_code": "8518.10.00"}'
/v1/usage
Your credit balance and, per period, your consumption: for classification and document validation separately and together, the number of model calls, tokens (including cache) and credits, plus the number of validated documents. Optional query parameters from and to (YYYY-MM-DD, inclusive); default is the current month.
consumption.classification, consumption.document and consumption.total: count (classifications or documents with usage in the period), calls, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens and credits (net, refunds deducted). consumption.document.validated counts the documents uploaded in the period that reached a final status. The existing fields are unchanged.
Response 200 OK
curl "https://import8.nl/api/v1/usage?from=2026-10-01&to=2026-10-31" \
-H "Authorization: Bearer c8_your_api_key"
{
"success": true,
"data": {
"billing_exempt": false,
"credits": {
"remaining": 247,
"total_purchased": 250,
"total_used": 3,
"earliest_expiry": "2027-03-26T00:00:00+00:00"
},
"credits_days_left": 41,
"credits_run_out_date": "2026-12-08",
"limits": { "rate_limit_per_minute": 20, "max_api_keys": 3 },
"balances": [ ... ],
"period": { "from": "2026-10-01", "to": "2026-10-31" },
"consumption": {
"classification": {
"count": 2, "calls": 9,
"input_tokens": 61240, "output_tokens": 4180,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 12800,
"credits": 2
},
"document": {
"count": 1, "calls": 2,
"input_tokens": 14310, "output_tokens": 2650,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 0,
"credits": 1, "validated": 1
},
"total": {
"count": 3, "calls": 11,
"input_tokens": 75550, "output_tokens": 6830,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 12800,
"credits": 3
}
}
}
}
/v1/me
Get account information for the authenticated API key, including organization details and credit balance.
Response 200 OK
{
"success": true,
"data": {
"organization": {
"name": "Acme Trading Co.",
"email": "api@acmetrading.com",
"country": "NL"
},
"credits": {
"remaining": 247,
"total_purchased": 250,
"total_used": 3,
"earliest_expiry": "2027-03-26T00:00:00+00:00"
},
"limits": {
"rate_limit_per_minute": 20,
"max_api_keys": 3
}
}
}
/health
Check the API status. This endpoint does not require authentication.
curl https://import8.nl/api/health
# Response:
{
"success": true,
"data": {
"status": "operational"
}
}
/v1/duty-rates/{hs_code}
Retrieve duty rates for a 10-digit HS code. Returns MFN duties, preferential duties, anti-dumping duties, and VAT rate.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
| origin | string | 2-letter ISO country code to filter preferential duties |
| destination | string | 2-letter ISO country code for VAT calculation (default: NL) |
Example
curl "https://import8.nl/api/v1/duty-rates/8518300090?origin=CN&destination=NL" \
-H "Authorization: Bearer c8_your_api_key"
Response 200 OK
{
"success": true,
"data": {
"hs_code": "8518300090",
"description": "Headphones and earphones",
"mfn_duty": {
"rate": "2.00%",
"expression": "2.000 %"
},
"preferential_duties": [
{
"scheme": "GSP",
"rate": "0.00%",
"countries": ["CN", "VN", "BD"]
}
],
"anti_dumping": [],
"vat_rate": "21.00%"
}
}
/v1/landed-cost
Calculate total landed cost for importing a product. Combines CIF value, duties, anti-dumping duties, and VAT.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| hs_code | string | required | 10-digit HS commodity code |
| origin_country | string | required | 2-letter ISO country code |
| destination_country | string | optional | Default: NL |
| value | number | required | Product value (min: 0.01) |
| currency | string | optional | Default: EUR |
| shipping | number | optional | Shipping costs (default: 0) |
| insurance | number | optional | Insurance costs (default: 0) |
Response 200 OK
{
"success": true,
"data": {
"cif_value": 31.50,
"duty": {
"rate": "0.00%",
"amount": 0.00,
"type": "preferential"
},
"anti_dumping": {
"rate": "0.00%",
"amount": 0.00
},
"vat": {
"rate": "21.00%",
"amount": 6.62
},
"total_landed_cost": 38.12,
"currency": "EUR"
}
}
/v1/documents/validate
Upload a trade document and get back what is wrong or missing for an import declaration: each finding comes with the place on the document, a ready-to-send correction and the source of the requirement. One credit per document. Supported document types:
commercial_invoicecommercial invoice (final invoice for the goods).proforma_invoiceproforma invoice (PI): the same checks as the commercial invoice, plus the signal that a proforma is the basis for the customs value only by exception. A commercial invoice submitted as proforma is not an error.freight_invoicefreight, logistics or commission invoice: which costs belong in the customs value (transport and insurance up to the place of entry, air freight from China at 70%, selling commission yes and buying commission no, goods amounts paid through the forwarder), and for an EU service provider the VAT invoice requirements (Article 226 VAT Directive). A forwarder outside the EU has no prescribed form.payment_proofpayment proof (Ebury, Wise, bank confirmation): amount paid, currency, date, payer, beneficiary and payment reference, and the transaction value signals (payment to a third party, deposit or partial payment, a payment that does not match the invoice, one payment for several invoices). A payment receipt added as an extra page after the invoice is read too; the main document is then in detected_type and the other documents in extracted.embedded_documents. No prescribed form.packing_listpacking list: net mass, gross mass, kind and number of packages, marks and numbers, and per line the description and quantity; a signal when the lines (pieces, cartons, weights, CBM) do not add up to the totals. Tips: carton dimensions, CBM and article numbers. A combined "Invoice & Packing List" counts too. No prescribed form.air_waybillair waybill (AWB, master and house, also a courier AWB or an AWB as an extra page after the invoice): the prescribed content of the Montreal Convention, Article 5 (departure, destination, a stopping place when both are in one country, weight) as errors; the AWB number and the master number on a house AWB, number of pieces, shipper and consignee for the ENS; a signal for dangerous goods (lithium UN3480/3481/3090/3091, PI 965-970) without a Shipper's Declaration, unless Section II. Tip: 11-digit IATA number with check digit. The AWB data are in extracted.transport_documents.bill_of_ladingbill of lading (B/L) or sea waybill, master and house: the B/L number and the master number on a house B/L, containers with seal numbers, gross mass, packages, marks, ports of loading and discharge (place of entry = first EU port), shipper, consignee and a notify party for a B/L made out to order. Hague-Visby Article III(3) (marks, packages or weight, apparent condition) is a signal, not an error: the convention does not automatically apply to a B/L from China. Tips: VGM (a loading condition, not a customs document) and the ISO 6346 check digit of the containers. The data are in extracted.transport_documents.declaration_of_conformityEU declaration of conformity (CE, DoC): the model structure of Decision 768/2008 Annex III as errors (product identification, name and address of the manufacturer or authorised representative, sole responsibility statement, object, legislation, standards, notified body if named, signature with place, date, name, function and signature), plus per act: standards with date or version (EMC, RED) and a colour image (toys 2009/48). A "CE certificate" or "Certificate of Compliance" of a test laboratory is not a DoC. With classification_uuid: the CE acts Import8 always requires for that HS code that the declaration does not name (gap), and a tip if the model on the declaration does not match the classification. Tips: accessories/software (RED), a Dutch version (requirement not verified). The data are in extracted.manufacturer, product, legislation, acts, standards, notified_body and signature.test_reporttest report or EU-type examination certificate (also a test laboratory's "CE certificate"). A test report has no legal form; its checks are tips: standards with year or version, model number, issuing body, date, overall conclusion. Errors apply only to the EU-type examination certificate of a notified body (RED, EMC): manufacturer with address, type identification, conclusion and essential requirements covered, and under the toy regulation 2025/2509 also a colour image, dimensions, the tests with a reference to the report and the place of manufacture. Signals: expired certificate, failed test, an "LVD certificate" (the LVD has no type examination). The data are in extracted.certificate, manufacturer, product, standards and notified_body.gpsr_labelGPSR label: a photo (JPG/PNG) or PDF of the label, the packaging or the product. Errors under the GPSR (2023/988): type, batch or serial number (Art. 9(5)), name or trade mark, postal and electronic address of the manufacturer (Art. 9(6)), for a manufacturer outside the EU the importer with postal and electronic address (Art. 11(3)) and an economic operator in the EU with postal address (Art. 16, Regulation 2019/1020 Art. 4; the importer counts). Tips: warnings not in Dutch (language requirement assumed, not verified), no QR code, and with classification_uuid: no CE marking visible while Import8 expects CE legislation for that HS code (informative). The data are in extracted.manufacturer, importer, eu_responsible, product, languages and label.insurance_certificateinsurance certificate or policy for the shipment. No content is prescribed by law (no errors). Gaps for the customs value: sum insured and currency, premium (not under CIF/CIP, where the insurance is in the price), where the cover starts and ends, and an invoice or transport reference; a signal that the insurance cost up to the place of introduction belongs to the customs value when the shipment is actually insured and the term is not CIF/CIP (UCC Art. 71(1)(e)(i), UCC IA Art. 137-138). Tips: cover below 110% of the invoice value under CIF/CIP (Incoterms 2020, private law) and missing cover conditions. The data are in extracted.insurance, incoterm and transport_mode.certificate_of_originproof of origin: REX statement on origin (also on an invoice), EUR.1, Form A or non-preferential certificate (CCPIT, chamber of commerce). Signal: a preferential proof with China as the origin gives no preference (China has not been a GSP beneficiary since 1 January 2015, Regulation 2015/1978; no free trade agreement). Errors apply only when a preference is possible: for the REX statement the fixed wording, the REX number above EUR 6,000, the origin criterion (P/W) per product and English, French or Spanish (UCC IA Art. 92, Annex 22-07; no signature needed), for the EUR.1 the customs endorsement (Annex 22-10). Non-preferential: no content prescribed (UCC Art. 61); gaps for the country of origin per product (16 08, also for anti-dumping) and the description. Tips: Form A (replaced by REX), issuing body. The data are in extracted.origin, origin_country and lines (with origin_criterion).cmrCMR consignment note (road, also as an extra page): the eleven particulars of CMR Article 6(1) as errors (place and date of making out; sender, carrier and consignee with address; place and date of taking over and place of delivery; nature of the goods and packing, for dangerous goods their description; packages with marks and numbers; gross weight; carriage charges; customs instructions when there are customs formalities; the CMR statement). Gaps: CMR number, and for direct road transport from outside the EU the customs office of entry (place of introduction) and a signal without TIR carnet or T1/MRN (transit is not mandatory, UCC Art. 226(3)). Tip: the list of documents handed to the carrier (Art. 6(2)(g)). The data are in extracted.transport_documents.cimCIM consignment note (rail), also the common CIM/SMGS consignment note. Errors: no CIM consignment note or an SMGS note only (CIM Art. 6 § 7: on EU territory every consignment needs a CIM consignment note), and the particulars of CIM Art. 7 § 1 (place and date of making out; consignor, carrier and consignee with address; place and date of taking over and place of delivery; nature of the goods and packing, for dangerous goods the RID description; packages, wagon number or container; marks for less than wagon loads; gross mass; list of customs documents; the CIM statement). Gaps: consignment note number, packages, and for transport from outside the EU the customs office of entry (place of introduction) and a signal without T1/MRN (transit is not mandatory, UCC Art. 226(3)). Tips: carriage charges payable by the consignee, seals. The data are in extracted.transport_documents (kind cim, smgs = CIM/SMGS).un38_3_summaryUN38.3 test summary of a lithium cell or battery. Errors: the ten items of UN Manual 38.3.5 as listed in 49 CFR 173.185(a)(3) (UN text not verified): manufacturer and its contact details, test laboratory, unique report number, date, description (lithium ion or lithium metal, mass, Wh rating or lithium content, physical description, model), the tests with pass/fail, for a battery the reference to the tests for assembled batteries, the edition of the UN Manual, and name and title of the signatory. Signal: a failed test. Tips (not verified): T1-T5 not all listed, above 100 Wh (battery) or 20 Wh (cell) for the transport classification, an edition before Rev. 8, and with classification_uuid an HS code Import8 does not link to batteries (informative). The data are in extracted.battery, manufacturer, product and signature.safety_data_sheetsafety data sheet (SDS, MSDS) under REACH Annex II as replaced by Regulation 2020/878. Errors: missing sections 1-8, missing sections 9-16 and sections not in the order 1-16 (sections 9-16 and the order not verified), section 1: product identifier (1.1), identified uses (1.2), supplier with address, phone and e-mail (1.3), emergency telephone number (1.4), and section 14 without a UN number or a statement that the product is not dangerous goods (not verified). For an article such as a battery (also recognised by UN3480/3481/3090/3091) an SDS is usually voluntary (REACH Art. 31, not verified): then no errors, but one tip listing what is missing. Tip: not in Dutch (language requirement assumed, not verified). The data are in extracted.sds, seller and languages.
Small documents (up to 5 pages and 5 MB) are processed while you wait and the result comes back directly (200); the time depends on the number of item lines; a document that does not finish within 24 seconds continues in the background and the call returns 202 as well. Larger documents, or any request with async=true, return 202 with a uuid; poll GET /v1/documents/{uuid} until the status is no longer processing. The response has the same shape either way. Allowed files: PDF, JPG, PNG, XLSX, XLS, at most 20 MB per file (larger files return 422); documents over 5 MB or 5 pages are always processed asynchronously. A spreadsheet (XLSX, XLS) counts one page per sheet; in its findings evidence.page is the sheet number, evidence.sheet the sheet name and the quote contains the cell address (e.g. B4: 123). Set a client timeout of at least 40 seconds for synchronous calls.
Files are not stored; the result can be retrieved for 24 hours. The uploaded file is deleted as soon as processing has finished (or failed). The extracted data and the findings remain available via GET /v1/documents/{uuid} for 24 hours after upload and are then deleted. What remains is a log entry without content: types, status, the number of errors, gaps and tips, the finding codes, pages, file size, duration and credits.
Request Body multipart/form-data
| Parameter | Type | Required | Description |
|---|---|---|---|
| file | file | required | The document: PDF, JPG, PNG, XLSX or XLS. Not empty, not password-protected. |
| document_type | string | optional | Document type: commercial_invoice, proforma_invoice, freight_invoice, payment_proof, packing_list, air_waybill, bill_of_lading, declaration_of_conformity, test_report, gpsr_label, insurance_certificate, certificate_of_origin, cmr, cim, un38_3_summary, safety_data_sheet. Other values return 422 listing the supported types. Leave it out and Import8 detects the type itself and applies the checks of that type (type_detected true in the response; extraction then reads all fields, so it costs a little more model time). A document that is none of these types gets document_type other and the error document_type_not_recognized. |
| transport_mode | string | optional | Transport mode: sea, air, rail, road or post. Used for the freight and insurance checks. |
| import_country | string | optional | Country of import: NL or DE, default NL. Decides the language and the requirements per country. With the header Accept-Language: de the result is in German. |
| importer_name | string | optional | Name of the importer if that is not your organisation, for example your end client. The buyer check and the corrections use this name. Deleted together with the content. |
| classification_uuid | string | optional | Uuid of one of your own classifications. The invoice HS codes are compared with that classification; for a declaration of conformity the required CE acts and the model; for a GPSR label the CE marking; for a UN38.3 test summary whether the HS code is linked to batteries. |
| async | boolean | optional | true to always get 202 and poll for the result. |
Example
curl -X POST "https://import8.nl/api/v1/documents/validate" \
-H "Authorization: Bearer c8_your_api_key" \
-H "Accept-Language: en" \
-F "file=@invoice.pdf" \
-F "document_type=commercial_invoice" \
-F "transport_mode=sea"
Response 200 OK / 202 Accepted
{
"success": true,
"data": {
"uuid": "9f1c2a4e-5b7d-4c1a-8e0f-3a6d2b9c7e11",
"document_type": "commercial_invoice",
"type_detected": false,
"detected_type": "proforma_invoice",
"transport_mode": "sea",
"classification_uuid": null,
"status": "incomplete",
"findings": [
{
"layer": "declaration",
"severity": "gap",
"code": "goods_code_missing",
"field": "lines[].hs_code",
"paths": ["lines[0].hs_code", "lines[1].hs_code"],
"message": "The commodity code (at least the 6-digit HS code) is missing on one or more item lines.",
"evidence": { "page": 1, "sheet": null, "quote": null },
"correction": "Please state at least the 6-digit HS code for each item.",
"source": "GV 2015/2446 bijlage B, 18 09; ICS2/ENS: HS op 6 cijfers"
},
{
"layer": "declaration",
"severity": "gap",
"code": "gross_mass_missing",
"field": "gross_kg",
"paths": ["gross_kg"],
"message": "The gross mass is missing.",
"evidence": { "page": 1, "sheet": null, "quote": null },
"correction": "Please state the gross weight in kg.",
"source": "GV 2015/2446 bijlage B (brutomassa; bij de ENS zoals op het vervoerdocument)"
},
{
"layer": "common",
"severity": "tip",
"code": "currency_not_usd",
"field": "currency",
"paths": ["currency"],
"message": "The invoice is not in USD. Any currency is legally allowed, but forwarders often ask for USD.",
"evidence": { "page": 1, "sheet": null, "quote": "Total: CNY 12,480.00" },
"correction": "Forwarders usually expect the invoice in USD.",
"source": "Praktijk expediteurs (laag C), geen wettelijke eis; wettelijk mag elke valuta, omrekening volgens UVo 2015/2447 art. 146"
}
],
"correction_request": "Please issue a corrected invoice: 1. Please state at least the 6-digit HS code for each item. 2. Please state the gross weight in kg.",
"extracted": { "invoice_number": "EX-2026-0412", "currency": "CNY", "lines": [ "..." ] },
"created_at": "2026-10-08T10:15:30+00:00"
},
"usage": { "credits_remaining": 41 }
}
correction_request is one English text for the supplier that combines the corrections of all errors and gaps. It is null when there is nothing to correct. extracted holds the data read from the document.
Status
The status summarizes the findings. Resolve errors first, then gaps.
| Status | Description |
|---|---|
| processing | Still being processed (asynchronous requests). Poll again in a few seconds. |
| failed | Processing failed. The credit has been refunded; submit the document again. |
| invalid | At least one error: the document is not a goods invoice or lacks legally required content. |
| incomplete | No errors, but at least one gap: data needed for the declaration is missing. It may come from another document (packing list, transport document). |
| complete_with_tips | No errors or gaps, only tips that forwarders commonly ask for. |
| complete | Nothing to report. |
The three layers
Every finding belongs to one layer, and the layer fixes its severity.
| Layer | Description |
|---|---|
| legal → error | Content required by law for the document itself. Severity error. |
| declaration → gap | Needed for the customs declaration or the customs value, but may be supplied by another document. Severity gap. |
| common → tip | Frequently requested by forwarders; not required. Severity tip. |
Findings
| Field | Description |
|---|---|
| layer | legal, declaration or common. |
| severity | error, gap or tip. |
| code | Stable rule code, e.g. goods_code_missing. |
| field | The field concerned, e.g. lines[].hs_code. |
| paths | The exact places in extracted that the finding concerns, e.g. lines[2].hs_code. |
| message | Explanation in the language of the Accept-Language header (nl or en). |
| evidence | page, sheet and quote: where on the document the finding is based, when applicable. For a spreadsheet (XLSX, XLS) page is the sheet number, sheet the sheet name (otherwise null) and the quote contains the cell address. |
| correction | What the supplier should change, in English, ready to send. |
| source | The legal or practical source of the requirement. |
Errors
| Status | Description |
|---|---|
| 422 | Unsupported document_type; missing, empty, unreadable, encrypted or too large file; or a classification_uuid that is not yours. The errors object names the field. |
| 413 | The request body exceeds the server limit before it reaches the API (JSON, not an HTML page). Send a file of at most 20 MB. |
| 429 | Not enough credits. The document is not stored. |
| 502 | Processing failed during a synchronous call. No credit is charged; try again. |
/v1/documents/{uuid}
Returns the same object as the validate call. Use it to poll after a 202, or to fetch a result again.
After 24 hours the call still answers with the log entry: content_purged is true, findings is empty, extracted and correction_request are null, and finding_counts / finding_codes still show what was found. The response then carries a message that the content was deleted. content_expires_at tells you until when the content is available.
Example
curl "https://import8.nl/api/v1/documents/9f1c2a4e-5b7d-4c1a-8e0f-3a6d2b9c7e11" \
-H "Authorization: Bearer c8_your_api_key"
404 — Unknown uuid, or a document of another organization.
/v1/documents/{uuid}/pdf
The same report as in the dashboard, as a PDF (application/pdf): outcome, findings with location and legal basis, and the message to the supplier. The language follows the Accept-Language header. Returns 404 for an unknown uuid, while the document is still processing or has failed, and after the content was deleted (24 hours).
curl "https://import8.nl/api/v1/documents/9f1c2a4e-5b7d-4c1a-8e0f-3a6d2b9c7e11/pdf" \
-H "Authorization: Bearer c8_your_api_key" \
-o document-check.pdf
/v1/document-sets
Upload the documents of one shipment together (commercial invoice, packing list, transport document, certificates) and get one report for the whole set: the findings per document, which findings another document in the set already covers, which required documents are missing, and one message for the supplier. The documents are checked one by one as with POST /v1/documents/validate, then the set is evaluated as a whole. The call always returns 202 with a uuid; poll GET /v1/document-sets/{uuid} until status is no longer processing.
Credits: 1 per document plus 1 for the set. The credits are reserved up front and atomically; with too few credits the call returns 429 and nothing is stored. A document that fails to process is refunded.
At most 20 files per set and 50 MB in total after unpacking; per file the limits of the document check apply (PDF, JPG, PNG, XLSX, XLS, at most 20 MB). A zip is recognised by its content and unpacked (also when it is sent inside files[]); zip bombs, nested zips and encrypted files are not unpacked. A file that cannot be checked does not stop the set: it is skipped and listed in skipped with a reason (unsupported_type, unreadable, too_large, set_too_large, too_many_files, nested_zip, encrypted, too_many_entries). When no file can be checked at all, the call returns 422.
Request Body multipart/form-data
| Parameter | Type | Required | Description |
|---|---|---|---|
| files[] | file[] | required without zip | The documents as an array of files, or one zip. Required unless zip is given. |
| zip | file | required without files[] | One zip with the documents. Required unless files[] is given. |
| reference | string | optional | Your own reference for the shipment (at most 191 characters); shown in the report and the message to the supplier. |
| transport_mode | string | optional | Transport mode: sea, air, rail, road or post. Decides which transport document is expected. |
| import_country | string | optional | Country of import: NL or DE, default NL. Decides the language and the requirements per country. With the header Accept-Language: de the result is in German. |
| importer_name | string | optional | Name of the importer if that is not your organisation, for example your end client. The buyer check and the corrections use this name. Deleted together with the content. |
| classification_uuids[] | string[] | optional | Uuids of your own classifications (at most 50); used for the checks per HS code. |
Example
curl -X POST "https://import8.nl/api/v1/document-sets" \
-H "Authorization: Bearer c8_your_api_key" \
-H "Accept-Language: en" \
-F "files[]=@invoice.xlsx" \
-F "files[]=@packing-list.xlsx" \
-F "reference=AQ8-2026-10" \
-F "transport_mode=sea"
Response 202 Accepted
{
"success": true,
"data": {
"uuid": "5b0d6f9e-3a41-4c8e-9d2a-7e1f0c4b8a63",
"reference": "AQ8-2026-10",
"transport_mode": "sea",
"status": "processing",
"verdict": null,
"documents": [ { "uuid": "...", "file_name": "invoice.xlsx", "status": "processing", ... } ],
"counts": null,
"skipped": [],
"file_count": 2,
"credits": 3,
"content_purged": false
},
"message": "Document set queued. Poll GET /v1/document-sets/{uuid} for the result.",
"usage": { "credits_remaining": 97 }
}
Errors
| 422 | No file can be checked (the skipped array says why), neither files[] nor zip is given, a transport_mode that is not supported, or a classification_uuids entry that is not yours. The errors object names the field. |
| 429 | Not enough credits for the set (1 per document plus 1 for the set). Nothing is stored. |
/v1/document-sets/{uuid}
Returns the set. Use it to poll after the 202, or to fetch the result again. Only sets of your own organization; otherwise 404.
The content of a set is deleted 24 hours after upload, like that of a document. The call then still answers with the log entry: content_purged is true, counts, missing_documents and correction_request are empty or null, and the response carries a message.
Example
curl "https://import8.nl/api/v1/document-sets/5b0d6f9e-3a41-4c8e-9d2a-7e1f0c4b8a63" \
-H "Authorization: Bearer c8_your_api_key"
Response 200 OK
{
"success": true,
"data": {
"uuid": "5b0d6f9e-3a41-4c8e-9d2a-7e1f0c4b8a63",
"reference": "AQ8-2026-10",
"transport_mode": "sea",
"status": "completed",
"verdict": "incomplete",
"documents": [
{
"uuid": "9f1c2a4e-5b7d-4c1a-8e0f-3a6d2b9c7e11",
"file_name": "invoice.xlsx",
"document_type": "commercial_invoice",
"type_detected": true,
"status": "incomplete",
"set_status": "incomplete",
"finding_counts": { "error": 0, "gap": 3, "tip": 1 },
"covered_count": 1,
"url": "https://import8.nl/api/v1/documents/9f1c2a4e-..."
}
],
"covered_findings": [
{
"document_uuid": "9f1c2a4e-...", "document_type": "commercial_invoice",
"file_name": "invoice.xlsx", "code": "gross_mass_missing",
"field": "gross_kg", "message": "...",
"covered_by": { "document_uuid": "c2b7...", "document_type": "packing_list",
"file_name": "packing-list.xlsx", "field": "gross_kg" }
}
],
"missing_documents": [
{
"type": "bill_of_lading", "label": "Bill of lading",
"requirement": "required", "required": true, "issuer": "forwarder",
"reasons": [ { "text": "...", "requirement": "required", "condition": null,
"source": "...", "hs_codes": [], "regime_code": null,
"incoterm": null, "determined": [] } ]
}
],
"possible_issues": [
{
"hs_code": "8414809000", "product": "Tire air pump",
"condition_key": "...", "condition": "...", "evidence": false, "applies": false,
"regimes": [ { "code": "...", "name": "EMC" } ],
"documents": [ { "type": "declaration_of_conformity", "label": "Declaration of conformity" } ],
"issuer": "supplier", "sources": ["..."], "text": "..."
}
],
"not_applicable": [],
"counts": { "error": 0, "gap": 2, "tip": 1, "missing_documents": 1,
"recommended_documents": 1, "possible_issues": 1, "failed_documents": 0,
"inconsistencies": 1 },
"skipped": [ { "name": "notes.txt", "reason": "unsupported_type", "message": "..." } ],
"hs_codes": ["8414809000"],
"incoterm": "FOB",
"transport_mode_used": "sea",
"consistency": [
{ "code": "gross_mass_mismatch", "severity": "error", "topic": "gross mass",
"message": "Gross mass differs: 389.85 kg on the packing list, 400 kg on the air waybill (difference 10.15 kg, allowed 7.8 kg).",
"correction": "Please make the gross mass on the packing list (389.85 kg) and the air waybill (400 kg) the same.",
"source": "GV 2015/2446 bijlage B, 18 04 (brutomassa); DWU art. 15 lid 2 ...",
"responsible": "supplier_forwarder",
"locations": ["Packing list (packing.xlsx), sum of the lines", "Air waybill (hawb.pdf), p. 1: '30 CTNS 400 KG'"],
"documents": [ { "uuid": "...", "document_type": "packing_list", "field": "lines[].gross_kg", "page": null, "line": null, "quote": null },
{ "uuid": "...", "document_type": "air_waybill", "field": "transport_documents[0].gross_kg", "page": 1, "line": null, "quote": "30 CTNS 400 KG" } ] }
],
"correction_request": "Subject: Corrections and documents required for EU import shipment ...",
"file_count": 2,
"credits": 3,
"created_at": "2026-10-14T10:07:22+00:00",
"duration_ms": 41200,
"content_purged": false,
"content_expires_at": "2026-10-15T10:07:22+00:00"
},
"usage": { "credits_remaining": 97 }
}
Fields of the response
statusprocessing, completed or failed.verdictThe verdict on the whole set, with the same values as the document status: invalid, incomplete, complete_with_tips or complete. Null while the set is processing.countsThe one count of the set: open errors, gaps and tips over all documents (a gap covered by another document does not count), missing_documents (required), recommended_documents, possible_issues, failed_documents and inconsistencies (discrepancies between documents; they also count in errors, gaps and tips by their severity). Null when the content is not available.documentsOne entry per document: uuid, file_name, document_type, type_detected, status, set_status (the status in the context of the set), finding_counts, covered_count and url (GET /v1/documents/{uuid}).covered_findingsFindings that another document in the set already covers, each with document_uuid, document_type, file_name, code, field, message and covered_by (document_uuid, document_type, file_name, field). A covered gap does not count as open.missing_documentsDocuments the set lacks: type, label, requirement (required, if_applicable or recommended), required (boolean), issuer (supplier, forwarder or buyer) and reasons[] (text, requirement, condition, source, hs_codes, regime_code, incoterm and determined: why it is required when that was established for the product).possible_issuesWhat is not established and is not part of the verdict: per product (hs_code, product) the condition (condition_key, condition), regimes[] (code, name), documents[] (type, label), issuer, sources[], evidence and applies (booleans) and a neutral sentence in text.not_applicableRegimes that were established as not applicable for a product, with hs_code, product, regimes, documents and evidence.consistencyDiscrepancies between the documents of the set (parties, quantity and HS code per line invoice ↔ packing list, packages and gross mass ↔ transport document, payment ↔ invoice total, sum insured ↔ CIF value, certificate of origin ↔ invoice): code, severity, message, correction, legal source, who resolves it, and both places where the data was found (document, field, page or line, quote). Only when both documents are in the set and both fields were extracted.correction_requestOne English text for the supplier with the corrections of all documents and the additional documents. Null when there is nothing to ask. skipped lists the files that were not checked (name, reason, message).- Also: reference, transport_mode, hs_codes, incoterm, transport_mode_used, file_count, credits, created_at, duration_ms, content_purged and content_expires_at.
404 — Unknown uuid, or a document of another organization.
/v1/document-sets/{uuid}/pdf
The same report as the set report in the dashboard, as a PDF (application/pdf): the verdict, the actions (priority, action, who), the corrections per document, the missing and possible documents, the message to the supplier and the basis of the check. The language follows the Accept-Language header; the file name is document-set-check-{reference}-{date}.pdf (dossiercontrole-... in Dutch). Returns 404 for an unknown uuid, while the set is still processing or has failed, and after the content was deleted (24 hours).
curl "https://import8.nl/api/v1/document-sets/5b0d6f9e-3a41-4c8e-9d2a-7e1f0c4b8a63/pdf" \
-H "Authorization: Bearer c8_your_api_key" \
-o document-set-check.pdf
/v1/classifications/{id}/audit
An audited classification runs on the same engine as an autonomous one, but pauses where something is to decide: the product picture, decisive facts, the heading, open subdivisions and the outcome. Your auditor holds the conversation step by step, exactly as in the dashboard. Every step goes into the report and the dossier with the answer, name, time and reasoning. Start with mode=audited on POST /v1/classify.
An audited classification costs 1 credit, as in the dashboard. If the determination fails, the credit is refunded. A description or images only; product_url alone is not supported.
Endpoints
| Endpoint | Description |
|---|---|
| POST /v1/classify mode=audited | Start with mode=audited and optionally locale (language of the report and the questions). Returns 202 with id and status. |
| GET /v1/classifications/{id}/audit | The state: run.status (running, waiting, done, failed) and stage, the whole report as data (report: per message the step kind, number, text, GRI, stamp and reasoning), the answers so far with the English engine values next to them, and open_question. |
| POST /v1/classifications/{id}/audit/answers | Answer the open question with an option (an id from open_question.options) or free text in any language. |
| POST /v1/classifications/{id}/audit/steps/{step_id}/back | Back to an earlier step. Nothing is paid again; the later steps are kept but no longer count. |
| POST /v1/classifications/{id}/audit/attest | Confirm the outcome with the auditor's name. The determination is frozen after that. |
Answering
| Parameter | Type | Description |
|---|---|---|
| option | string | An id from open_question.options, e.g. confirm, assume (don't know), q:0 or line:123. records says what the option records. Recorded without a model call. |
| text | string | A free answer in any language. Unambiguous answers ("yes", "right", "don't know") need no model call; otherwise one cheap call reads the answer. When it is not unambiguous, you get a clarification question and nothing is recorded. |
| step_id | integer | Optional: the step you answer. If another step is waiting, you get 409. |
| answered_by | string | Name of your auditor; goes on the stamp. Defaults to the API key's name, else the organisation's. |
| locale | nl | en | Language of the texts in the response. Defaults to the language chosen at the start. |
The response has result: recorded (recorded; data is the new state, possibly with the next question or run.status=running), clarification (a question back in clarification; nothing recorded) or rejected (with reason).
Example: a whole conversation
question is the question writer's friendly question and may be null; question_template is always filled. Show question when there is one, the template otherwise.
# 1. Start
curl -X POST "https://import8.nl/api/v1/classify" \
-H "Authorization: Bearer c8_..." -H "Content-Type: application/json" \
-d '{"description": "Staande ventilator, 220v", "mode": "audited", "locale": "nl"}'
{"success": true, "data": {"id": "9f1c…", "mode": "audited", "status": "processing", "run_status": "running"}}
# 2. The state and the open question
curl "https://import8.nl/api/v1/classifications/9f1c…/audit" -H "Authorization: Bearer c8_..."
{"success": true, "data": {
"run": {"status": "waiting", "stage": "candidates"},
"open_question": {
"step_id": 41, "question_id": "picture", "kind": "product_profile",
"question": null,
"question_template": "Klopt dat?",
"why": null,
"context": {"lead": "Ik zie: een staande elektrische ventilator voor 220 V.", "body": "Bedoeld om lucht te laten circuleren."},
"options": [{"id": "confirm", "label": "Ja", "records": {"type": "confirm"}}],
"free_text_allowed": true
},
"report": [ … ], "answers": [ … ]
}}
# 3. The product picture is right; the decisive question follows
curl -X POST ".../audit/answers" -d '{"option": "confirm", "answered_by": "J. de Vries"}'
{"success": true, "result": "recorded", "data": {"open_question": {
"question_id": "fact:part_of", "kind": "product_profile",
"question": "Is het een complete ventilator, klaar voor gebruik, of een los onderdeel?",
"question_template": "Wat weet u over: onderdeel van?",
"why": "Een los onderdeel valt onder 8414 90 of de post van dat onderdeel zelf.",
"options": [
{"id": "q:0", "label": "Compleet, klaar voor gebruik", "records": {"type": "fact", "key": "part_of", "value": "complete fan, ready for use"}},
{"id": "q:1", "label": "Los onderdeel", "records": {"type": "fact", "key": "part_of", "value": "a separate part of a fan"}},
{"id": "assume", "label": "Weet ik niet", "records": {"type": "assumption"}}
],
"free_text_allowed": true
}}}
# 4. A free answer (any language)
curl -X POST ".../audit/answers" -d '{"text": "hij is compleet, met voet en kabel"}'
{"success": true, "result": "recorded", "answer": {"text": "hij is compleet, met voet en kabel", "english": "it is complete, with stand and cable", "interpreted_by": "model"}, …}
# 5. An answer that is not unambiguous: a question back, nothing recorded
{"success": true, "result": "clarification", "clarification": "Ik begrijp uw antwoord niet helemaal. Bedoelt u dat het een andere post is?"}
# 6. The heading
{"open_question": {"kind": "heading", "question": null, "question_template": "Gaat u akkoord met post 8414?",
"options": [{"id": "confirm", "label": "Akkoord", "records": {"type": "confirm"}}]}}
# 7. The outcome
{"open_question": {"kind": "outcome", "question_template": "Bevestigt u deze classificatie?",
"context": {"code": "8414.51.00.00"}, "options": [{"id": "confirm", "label": "Ik bevestig deze classificatie", "records": {"type": "confirm_outcome"}}],
"free_text_allowed": false}}
# 8. Attest
curl -X POST ".../audit/attest" -d '{"answered_by": "J. de Vries"}'
{"success": true, "result": "attested", "data": {"attestation": {"attested_at": "2026-10-15T14:32:10+02:00", "attested_by": "J. de Vries"}}}
Webhooks
classification.awaiting_answer arrives as soon as the engine waits for an answer, with the open question (question and question_template) in the payload. A next question within the same step is in the response to your POST right away. classification.completed follows when the outcome is confirmed, classification.failed when the determination fails.
{"event": "classification.awaiting_answer",
"data": {"id": "9f1c…", "status": "processing",
"open_question": {"step_id": 43, "question_id": "decisive", "kind": "heading",
"question": "Is het een complete ventilator of een los onderdeel?",
"question_template": "Wat weet u over: onderdeel van?",
"why": "Een complete ventilator valt onder 8414, een los onderdeel elders.",
"options": [ … ], "free_text_allowed": true}}}
{"event": "classification.completed", "data": {"id": "9f1c…", "status": "completed"}}
{"event": "classification.failed", "data": {"id": "9f1c…", "status": "failed"}}
Conversation token and rate limit
Every open_question (and the state itself) carries a signed conversation_token, valid for 24 hours. Send it as header X-Import8-Conversation or field conversation_token with the /audit calls of the same classification: they then do not count against your organisation's rate limit but against a limit of their own per conversation (60 per minute). Without a token, with an invalid or expired one, or with one of another classification, the normal limit applies. The token grants no access: the c8_ key stays required.
Errors
| 404 | The classification does not exist, belongs to another organisation, or is not audited. |
| 409 no_open_question | No question is waiting: the engine is working or the determination is done. |
| 409 step_not_waiting | The given step_id is not the step that waits. |
| 409 busy / not_attestable | The engine is working, the step was already decided, or attesting is not possible (yet or any more). |
| 422 | Invalid input, e.g. an option that is not in the list, or neither option nor text. |
| 429 | The organisation's rate limit; each call of a conversation counts. |
/v1/webhook
Configure a webhook URL to receive push notifications when classifications complete or fail.
Note: The webhook is a notification only — it carries the classification id, not the result. Fetch the full data via GET /v1/classifications/{id}.
Example
curl -X PUT https://import8.nl/api/v1/webhook \
-H "Authorization: Bearer c8_your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhook"}'
Response 200 OK
{
"success": true,
"data": {
"url": "https://example.com/webhook"
}
}
/v1/webhook
Remove the configured webhook. You will stop receiving notifications.
curl -X DELETE https://import8.nl/api/v1/webhook \
-H "Authorization: Bearer c8_your_api_key"
# Response:
{
"success": true,
"message": "Webhook removed."
}
Webhook Events
When a classification finishes processing, Import8 sends a small POST notification to your webhook URL, with the event type in the X-Import8-Event header.
| Event | Description |
|---|---|
| classification.completed | Classification finished successfully |
| classification.failed | Classification failed due to an error |
| classification.insufficient_information | The product could not be identified from the description; no code, credit refunded (see the insufficient object) |
| classification.awaiting_answer | An audited classification waits for an answer; the payload carries the open question. |
Headers
| Header | Description |
|---|---|
| X-Import8-Event | Event type (e.g. classification.completed) |
| User-Agent | Import8-Webhook/1.0 |
Example Payload
{
"event": "classification.completed",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed"
}
}
The webhook is a signal, not the data. It only tells you a classification is ready (or failed) and carries its id. Fetch the full result — HS code, risk grading, documents — via the authenticated GET /v1/classifications/{id}. Because the notification contains no sensitive data and the result sits behind your API key, the webhook is not signed.
Verifying the signature
Every webhook is signed. The header X-Import8-Signature: t=1760536330,v1=5f2c… carries a Unix timestamp t and v1: a hex HMAC-SHA256 with your signing secret over t, a dot and the raw body exactly as received. You see and rotate the secret on the webhook page of the dashboard (it starts with whsec_). Compare the HMAC in constant time and reject a timestamp more than 5 minutes off; that stops a captured message being replayed later.
# PHP
$body = file_get_contents('php://input'); // raw body, before any json_decode
$header = $_SERVER['HTTP_X_IMPORT8_SIGNATURE'] ?? ''; // "t=...,v1=..."
parse_str(str_replace(',', '&', $header), $sig);
$ok = isset($sig['t'], $sig['v1'])
&& abs(time() - (int) $sig['t']) <= 300 // replay tolerance: 5 minutes
&& hash_equals(hash_hmac('sha256', $sig['t'].'.'.$body, getenv('IMPORT8_WEBHOOK_SECRET')), $sig['v1']);
if (! $ok) { http_response_code(401); exit; }
# Python
import hmac, hashlib, time
parts = dict(p.split('=', 1) for p in header.split(','))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
ok = abs(time.time() - int(parts['t'])) <= 300 and hmac.compare_digest(expected, parts['v1'])
Rate Limits
Rate limits are applied per API key and depend on your credit package tier. Every API response includes rate limit headers:
| Header | Description |
|---|---|
| X-RateLimit-Limit | Max requests per window |
| X-RateLimit-Remaining | Remaining requests in current window |
| X-RateLimit-Reset | Unix timestamp when window resets |
When exceeded, the API returns 429 Too Many Requests with a Retry-After header.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
{
"success": false,
"message": "Rate limit exceeded. Please retry after 30 seconds."
}
Error Codes
The API uses standard HTTP status codes:
| Status | Meaning |
|---|---|
| 200 | Success |
| 202 | Accepted -- classification queued |
| 401 | Invalid or missing API key |
| 404 | Resource not found |
| 422 | Validation error -- check request body |
| 429 | Rate limit or usage limit exceeded |
| 500 | Server error |
Error Response Format
{
"success": false,
"message": "Description of the error"
}