GET /label/chem/classify

Resolves a substance identifier into its harmonised classification and labelling under the CLP Regulation, Annex VI (ECHA): the hazard classes with their H-statements, GHS pictograms, signal word, concentration limits and M-factors — exactly as published, and versioned by ATP (the entry in force at the queried date, plus any published-but-not-yet-applicable change). The answer is served from a local store built from the official Annex VI dataset, so a lookup resolves in milliseconds with no network call and no secret at request time.

Use it when an agent needs a grounded, dated, sourced verdict before shipping an EU label, a safety data sheet or a formulation — “is this substance harmonised under CLP, and what does its label say?”, “does the classification change under the next ATP?”. This is the differentiator: Annex VI moves by an annual ATP that lands after model cutoffs (the 22nd ATP applies 2026-05-01, the 23rd is published and applies 2027-02-01), so an LLM answers from a stale or invented classification where this endpoint answers from the version in force at the date you ask. Lookup is by CAS, EC or exact name only — there is no fuzzy search in v1. See the live /catalog for the authoritative endpoint listing and price.

This endpoint returns the Annex VI entry as sourced, dated, versioned facts. It is never legal advice, a computed classification for a mixture, nor a generated safety data sheet. That line is the boundary of the service — and an absence from Annex VI does not mean the substance is not hazardous.

x402 golden rule: the agent pays for the answer to its question. A well-formed key that Annex VI does not carry is a successful answer too → 200 billed with verdict: "not_harmonised" — the absence is the information, never a 404. Only requests the service cannot answer — no key, several keys, a bad checksum, a malformed at — leave the 200 range and are not billed.

Parameters

Exactly one of the three keys, as a query parameter. Zero or several keys, a bad checksum, an empty name or a malformed at is a 400 INVALID_INPUT.

ParameterTypeRequiredDescription
casstringone of the threeCAS number — check-digit validated (weighted-sum mod 10, the same rigour as the IBAN mod-97)
ecstringone of the threeEC number (EINECS/ELINCS) — check-digit validated
namestringone of the threeExact Annex VI name (case and spacing folded, no fuzzy match)
atstringnoYYYY-MM-DD — serves the classification in force at that date (defaults to today)
GET /label/chem/classify?cas=100-41-4&at=2026-06-01

cas and ec are validated by their check digit before any lookup — a wrong check digit is a 400 INVALID_INPUT with the rejected literal in the message, not a billed miss. An exact name is never disambiguated silently: an ambiguous name returns all of its Annex VI entries. at lets an agent probe around an ATP switchover — the same CAS can be not_harmonised before a date and harmonised after it, or carry different hazard classes across the boundary.

200 response — UnifiedResponse

{
  "data": { "verdict": "harmonised", "entries": [ ... ] },
  "provenance": {
    "source": "ECHA Annex VI (CLP)",
    "fetched_at": "2026-07-24T07:45:50Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-05-01T00:00:00Z" }
  }
}

freshness.as_of is dated by the ATP in force at the queried date, not by the ingestion date — the regulatory version is the point of the answer.

Fields of data

FieldTypeDescription
verdictstringharmonised (≥ 1 Annex VI entry in force) or not_harmonisedboth are billed 200s
entriesobject[]The Annex VI entries as published (see below); empty on not_harmonised
notestringnot_harmonised only — the contractual self-classification note (see below)
upcomingobjectPresent when a published but not-yet-applicable ATP will add this substance (not_harmonised)

A field the source does not carry is omitted from the JSON, never served null — the one exception being notes inside an entry, a full member of the published entry.

An entries[] entry — Annex VI as published

FieldTypeDescription
index_numberstringAnnex VI index number — the stable identity across ATPs
namestringSubstance name as published
ec_numbersstring[]EC number(s) as published
cas_numbersstring[]CAS number(s) as published
hazard_classesobject[]{ class, category, h_statement } — the H-statement is paired only when the source columns align, otherwise omitted, never guessed
hazard_statementsstring[]The flat list of H-statements, served exactly as published
pictogramsstring[]GHS pictogram codes (e.g. GHS02)
signal_wordstringDgr (Danger) or Wng (Warning)
label_hazard_statementsstring[]H-statements required on the label
suppl_hazard_statementsstring[]Supplemental EUH statements
concentration_limitsstringSpecific concentration limits as published
m_factorsstringM-factors as published
notesstringAnnex VI notes; null when the entry carries none
cmr_1a_1bboolDerived convenience flag: true iff a Carc./Muta./Repr. 1A or 1B class is among those served — a screening shortcut, not a judgment of ours
atpobject{ regulation, applicable_from } — the ATP version this entry is served from
upcomingobject{ change, regulation, applicable_from } when a published, not-yet-applicable ATP touches this entry

The hazard_classes pairing is positional and cautious: h_statement is filled only when the source columns have matching lengths; otherwise it is omitted rather than paired at random, and the flat hazard_statements list still carries everything. Classes, statements, pictograms, limits and M-factors are relayed exactly as published — they are the regulatory text, not a reformulation.

ATP versioning is the freshness differentiator

Each entry carries its atp (the version served) and, when relevant, an upcoming block: change is added, modified or removed, with the regulation and applicable_from of the ATP that will apply. This is what an LLM cannot hold — the classification a substance will have on a future date, and the date it flips. freshness.as_of is the applicability date of the ATP in force at your at.

Example — harmonised substance, resolved by CAS

GET /label/chem/classify?cas=100-41-4&at=2026-06-01 (ethylbenzene), served from the ATP in force on that date:

{
  "data": {
    "verdict": "harmonised",
    "entries": [
      {
        "index_number": "601-020-00-8",
        "name": "ethylbenzene",
        "ec_numbers": ["202-849-4"],
        "cas_numbers": ["100-41-4"],
        "hazard_classes": [
          { "class": "Flam. Liq.", "category": "2", "h_statement": "H225" },
          { "class": "Acute Tox.", "category": "4", "h_statement": "H332" },
          { "class": "STOT RE", "category": "2", "h_statement": "H373" },
          { "class": "Asp. Tox.", "category": "1", "h_statement": "H304" }
        ],
        "hazard_statements": ["H225", "H332", "H373", "H304"],
        "pictograms": ["GHS02", "GHS07", "GHS08"],
        "signal_word": "Dgr",
        "label_hazard_statements": ["H225", "H332", "H373", "H304"],
        "suppl_hazard_statements": [],
        "concentration_limits": "STOT RE 2; H373: C >= 10 %",
        "m_factors": "M = 1",
        "notes": null,
        "cmr_1a_1b": false,
        "atp": {
          "regulation": "Regulation (EU) 2024/2564 (22nd ATP)",
          "applicable_from": "2026-05-01"
        }
      }
    ]
  },
  "provenance": {
    "source": "ECHA Annex VI (CLP)",
    "fetched_at": "2026-07-24T07:45:50Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-05-01T00:00:00Z" }
  }
}

Example — an upcoming ATP change

GET /label/chem/classify?cas=111-15-9&at=2026-06-01 returns a harmonised entry that additionally carries:

"upcoming": {
  "change": "modified",
  "regulation": "Regulation (EU) 2025/… (23rd ATP)",
  "applicable_from": "2027-02-01"
}

The entry served is still the one in force on 2026-06-01; the upcoming block tells the agent the classification will change on 2027-02-01 and how.

Example — a well-formed key that is not harmonised (billed)

GET /label/chem/classify?cas=7732-18-5 (water) — a valid CAS that Annex VI does not carry — returns a 200, billed, with the self-classification note in plain sight, never a 404:

{
  "data": {
    "verdict": "not_harmonised",
    "entries": [],
    "note": "Absence does not mean the substance is not hazardous: self-classification under CLP remains the responsibility of the entity placing it on the market."
  },
  "provenance": {
    "source": "ECHA Annex VI (CLP)",
    "fetched_at": "2026-07-24T07:45:50Z",
    "freshness": { "kind": "snapshot", "as_of": "2026-05-01T00:00:00Z" }
  }
}

The agent asked “is this substance harmonised under CLP?”, and “Annex VI does not carry it” is the answer — with the note making explicit that a not-harmonised substance can still be hazardous. When a published ATP will add the substance, the response also carries the upcoming block. Contrast this with the 400/503 below, which are not billed.

Freshness

Answers are served from a snapshot of Annex VI, dated by the ATP in force at the queried date. Annex VI is refreshed off-band from the official ECHA dataset (a manual deposit, once or twice a year); if a refresh is missed, the service keeps serving the last good snapshot — honestly dated by as_of — and never turns staleness into a 5xx. A dataset not yet ingested at all is a 503 DATA_UNAVAILABLE (there is no last good snapshot to serve).

Errors

Only requests the service cannot answer leave the 200 range — none of them are billed.

StatuscodeCase
400INVALID_INPUTZero or several keys; a bad CAS/EC check digit; an empty name; a malformed at
503DATA_UNAVAILABLEThe Annex VI dataset has not been ingested yet — there is no last good snapshot to serve
404NOT_FOUNDUnknown route
500INTERNALInternal error (detail logged, not exposed)
{ "error": "invalid CAS check digit: '100-41-5'", "code": "INVALID_INPUT" }

A 503 for a dataset that is not yet ingested is distinct from staleness: a missed refresh serves the last snapshot, flagged by freshness.as_of, and is never a 5xx.

Attribution

Harmonised classification data is derived from Annex VI of the CLP Regulation, published by ECHA (legal notice — commercial reuse permitted). Annex VI is the regulatory text and is also available on EUR-Lex.

See also