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.

KeyRegister consultedprovenance.freshness.kind
cisBDPM (France)snapshot — monthly image
ndcopenFDA + RxNorm (US)snapshot — dump date
name + country=FR|USthe single register named by countrysnapshot
dcithe committed DCI↔INN substance tablelive — 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_cents is the medicine; price_with_dispensing_fee_cents is what is paid at the counter; dispensing_fee_cents is the pharmacist’s fee. A presentation whose price the register does not publish carries none of the three — never a guessed 0. 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_groups carries the group, not its members{ id, label } only. Listing the equivalents is the job of GET /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

  1. Discover the endpoint in the live catalog, call it, receive the 402.
  2. Pay — sign the chosen rail and replay the request.
  3. Read found, status and coverage together, never one alone.
  4. 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.