What is behind a CIS code or a US NDC?
One paid call turns a French CIS or a US NDC into an official identity card — status, prices, reimbursement rates — with the INN as the FR↔US bridge.
By Matthias Begot ·
An agent reading a prescription, an order line or a supplier catalogue holds an
opaque code: eight digits of a French CIS, or a US NDC like 58151-155.
GET /medication/resolve turns that code into
the official identity card behind it — marketing-authorisation status, active
substances normalised to their INN, boxes with their published price and
reimbursement rate, generic group — served from the French BDPM register or
from openFDA + RxNorm, on the same route. The one design decision worth an
article is the join key: every active substance is normalised to its INN, so a
French CIS and a US NDC for the same molecule carry the same
substances[].id. That id is what makes a US medication checkable against a
French thesaurus.
Two registers, one route, four keys
The endpoint takes exactly one key, and the key chosen decides which register answers and what provenance the response carries.
| Key | Register consulted | provenance.freshness.kind |
|---|---|---|
cis | BDPM (France) | snapshot — monthly image |
ndc | openFDA + RxNorm (US) | snapshot — dump date |
name + country=FR|US | the single register named by country | snapshot |
dci | the committed DCI↔INN substance table | live — pure computation |
A name without a country is a 400, and the reason is provenance rather
than parsing:
{ "code": "MISSING_PARAMETER", "error": "missing or empty required parameter: country" }
A bare ELIQUIS exists in both registers. Answering it from both would produce
one response with two sources and one as_of — a blended provenance that is
false about both. So the caller says which register it is asking, and the answer
names exactly one source. Zero or several keys is the other 400:
{ "code": "INVALID_INPUT", "error": "provide exactly one of: cis, ndc, name, dci" }
The identity card France publishes
France is the side that carries the most, because the BDPM publishes more than a
product listing. GET /medication/resolve?cis=60424338, against the
2026-08-08 snapshot:
{
"found": true,
"medications": [
{
"country": "FR",
"identifiers": { "cis": "60424338" },
"name": "AMOXICILLINE VIATRIS 500 mg, gélule",
"form": "gélule",
"routes": ["orale"],
"holder": "VIATRIS SANTE",
"status": "Autorisation active",
"marketing_status": "Commercialisée",
"authorization_date": "1998-12-10",
"enhanced_surveillance": false,
"dispensing": ["liste I"],
"substances": [
{
"id": "amoxicillin",
"labels_fr": ["amoxicilline", "amoxicilline trihydratée"],
"labels_en": ["amoxicillin"],
"raw_label": "AMOXICILLINE TRIHYDRATÉE",
"source_code": "28165",
"mapped": true
}
],
"presentations": [
{
"cip7": "3491317",
"cip13": "3400934913174",
"label": "plaquette(s) thermoformée(s) PVC-aluminium PVDC de 12 gélule(s)",
"collectivities_approved": true,
"reimbursement_rate": "65%",
"price_cents": 153,
"price_with_dispensing_fee_cents": 255,
"dispensing_fee_cents": 102
}
],
"generic_groups": [
{ "id": 148, "label": "AMOXICILLINE 500 mg - CLAMOXYL 500 mg, gélule" }
]
}
]
}
Three details in there are the ones agents get wrong when they scrape this data themselves.
- The three money fields are three separate BDPM columns, in euro cents,
never a monetary float and never recomputed from one another.
price_centsis the medicine;price_with_dispensing_fee_centsis what is paid at the counter;dispensing_fee_centsis the pharmacist’s fee. A presentation whose price the register does not publish carries none of the three — never a guessed0. The same ELIQUIS answer shows both cases side by side: the box of 60 tablets carries all three, the hospital pack of 100 single-tablet blisters carries no price at all. - No patient out-of-pocket amount is served. It would be an arithmetic invention layered on a relayed fact, and it depends on coverage the register does not know about.
generic_groupscarries the group, not its members —{ id, label }only. Listing the equivalents is the job ofGET /medication/generics, which returns the reference brand and each member with its own presentations.
A field the source does not carry is omitted, never served null: a US
result has no status or presentations, a French result has no
generic_name.
The substance is the bridge
Atorvastatin makes the mechanism concrete across the two registers.
On the US side, name=LIPITOR&country=US resolves to four NDCs from the
openFDA/RxNorm store:
{
"country": "US",
"identifiers": { "ndc": "58151-155" },
"name": "Lipitor",
"generic_name": "atorvastatin calcium",
"form": "TABLET, FILM COATED",
"holder": "Viatris Specialty LLC",
"dispensing": ["prescription"],
"substances": [
{
"id": "atorvastatin",
"labels_fr": ["atorvastatine", "atorvastatine calcique trihydratée"],
"labels_en": ["atorvastatin"],
"raw_label": "ATORVASTATIN CALCIUM TRIHYDRATE",
"source_code": "83367",
"mapped": true
}
]
}
On the French side, the same molecule arrives under a different published
label — ATORVASTATINE CALCIQUE TRIHYDRATÉE on one CIS,
ATORVASTATINE CALCIQUE on another — and both normalise to the same
id: "atorvastatin". Registers publish salt forms; the INN names the
active moiety. That is the whole reason the normalisation exists: string
matching ATORVASTATIN CALCIUM TRIHYDRATE against ATORVASTATINE CALCIQUE gets
you nothing, and neither does asking a language model to guess.
The WHO INN Programme, running since 1950, exists precisely to give each pharmaceutical substance a single globally accepted name. Using it as the primary key is not an Invoket convention — it is the identifier the sources themselves are anchored to.
What that id then unlocks is the point:
POST /medication/interactions is indexed
by the same canonical substance, so a list assembled from US NDCs can be checked
against the French ANSM interactions thesaurus — an authoritative source
with no US-published equivalent since the NLM retired its interaction API. The
bridge is not a convenience; it is what makes a cross-border check possible at
all.
When normalisation fails, the response says so
The service never guesses a substance it cannot place. ELIQUIS shows the failure mode, in production, today:
{
"substances": [
{
"id": "apixaban",
"labels_fr": [],
"labels_en": [],
"raw_label": "APIXABAN",
"source_code": "61321",
"mapped": false
}
],
"coverage": { "unmapped_substances": ["APIXABAN"] }
}
Read it strictly. mapped: false means the published label matched no entry in
the DCI↔INN table, so the id is a normalisation of that label rather than a
confirmed INN, and coverage.unmapped_substances names it at the top level so a
caller does not have to walk every substance array to find out. Ids can still
coincide across registers when both publish the same label — the US ELIQUIS
resolves to apixaban too — but a coincidence is not a guarantee, and the
response refuses to present it as one. mapped: true is the assertion; the
rest is a relayed label with a flag on it. When every substance normalised,
coverage is absent entirely.
That is the same discipline the rest of the family holds: a bound the service knows about is published as a field, not left for the caller to discover in production.
Facts as published, never a verdict
status and marketing_status are relayed verbatim from the register, and the
uncomfortable values are the ones that matter. Asking for TAHOR 10 mg, comprimé pelliculé returns several CIS codes carrying
"status": "Autorisation abrogée" and "marketing_status": "Non commercialisée" — revoked authorisations, still in the register, still
resolvable. That is a billed 200, not a 404: an agent handed a stale code
from an old catalogue needs to be told the authorisation is revoked, and
“revoked” is an answer worth paying for. The same logic runs through the
platform — an abrogated article of French law is a
200 too.
The US side needs its own caution, stated by the source: inclusion in the
NDC Directory does not mean the FDA has verified the submitted information
or approved the product. So found: true on an ndc asserts this product is
listed in the directory, nothing more. The endpoint relays labels, dates,
prices and rates exactly as published. It is never clinical advice, a patient
dosage, or a risk score — that line bounds the whole medication family.
A well-formed but unknown key is a paid answer
GET /medication/resolve?cis=99999999 returns a billed 200:
{
"data": { "found": false },
"provenance": {
"source": "BDPM",
"freshness": { "kind": "snapshot", "as_of": "2026-08-08T00:00:00Z" }
}
}
The agent asked “is this identifier a real medication?” and got the answer. The
absence is the information — and as_of tells you which image of the
register that absence is asserted against, which matters more here than
anywhere: NDCs are delisted, French authorisations are revoked, and a code that
resolved last quarter may not resolve today.
French answers come from a monthly BDPM snapshot, US answers from the dated
openFDA dump, both served with no network call and no secret at request time. A
missed refresh keeps serving the last good snapshot, honestly dated —
staleness is never turned into a 5xx. A register never ingested at all is
a 503, because there is nothing to serve. A dci key consults no store and
therefore can never return 503.
Why a French register is worth an endpoint
US drug data is abundant and free: openFDA is dedicated to the public domain under CC0, RxNorm is NLM-published, and a sizeable population of near-identical wrappers already sits on top of them.
France publishes its register just as freely — under
article L. 161-40-1 of the Social Security Code,
the BDPM is available for download and reuse, on the condition that the source
and its update date are cited. But it publishes it as tab-separated text files
refreshed monthly, with no official API in front of them. Everything between
those files and a resolvable identifier — parsing, joining specialities to
presentations to prices to generic groups, normalising salt forms to the INN —
is work every consumer would otherwise redo. That is the gap this endpoint
fills, and it is why the required attribution (source and date) is carried in
provenance on every single answer rather than buried in a footer.
Lookup is by identifier or exact name only. There is no search by symptom, indication or fuzzy name in v1 — the endpoint serves agents that already hold a code.
What you pay for
- Discover the endpoint in the live catalog, call it, receive the
402. - Pay — sign the chosen rail and replay the request.
- Read
found,statusandcoveragetogether, never one alone. - Branch — proceed, resolve the generic group, or escalate to a human.
A well-formed identifier the register does not carry is a billed 200. Only
requests the service cannot answer leave the 200 range, and none of them are
billed: zero or several keys, a CIS that is not eight digits, a package-level
NDC (58151-155-30 is malformed — the endpoint takes a product NDC), a name
without a country. Quickstart walks the full
discover → 402 → pay → replay cycle; price and accepted rails are served live
by the catalog, never pinned in an article.
Chain it
resolve is the entry point of the family, and each of the others takes the
identifier it hands back.
GET /medication/generics expands the
generic_groups label into its members and their public prices.
GET /medication/availability says whether
the ANSM has published a shortage report for the product.
POST /medication/interactions compares the
normalised substances against the ANSM thesaurus. For identity, generic group,
reported shortages and interactions on one medication in a single
settlement, use the composite
GET /medication/report.
For the full field reference, the coverage semantics and the error codes, see
the GET /medication/resolve documentation; for
how agents discover and call Invoket endpoints, see
For agents.