Skip to main content
The haau3 Terminology API is a hosted FHIR R4 terminology service. It serves standard FHIR terminology operations over clinical code systems, so you can look up codes, expand value sets, and validate codes without running a terminology server yourself. The open-source SDKs for TypeScript, Python and Go call every operation of this service, and each operation in the API reference has a sample in all three and in cURL.

Service base

Everything hangs off that base, exactly as the FHIR REST specification describes. /v1/ is the version of the haau3 platform API contract, not a FHIR version. The FHIR release is r4, and every response says so:
You never have to ask for a release. A plain Accept: application/fhir+json, or no Accept header at all, works fine. If you do ask for one we do not serve, or for a format other than JSON (in Accept or _format), you get a 406 rather than a silent substitution. Every error on this base is a FHIR OperationOutcome, including the 401 you get without a key and a 429, so a FHIR client only ever has one error shape to parse. Errors, limits and request ids lists the statuses every service shares.
The uppercase form /v1/fhir/R4/... redirects to the lowercase base with a 308, which preserves the method and body so a POST is not quietly turned into a GET.
The Provider directory, Questionnaires, Medical Benefits, MedList and Pharmacy Benefits are served from this same base, under the same CapabilityStatement.

Operations

Each requires a valid API key (see Authentication). Each accepts GET with query parameters or POST with a FHIR Parameters resource.
  • CodeSystem/$lookup — a code’s display and properties.
  • CodeSystem/$validate-code — is this a real code in this code system?
  • ValueSet/$expand — expand a value set to its member codes, or search one by name.
  • ValueSet/$validate-code — is this code a member of this value set?
  • metadata — the CapabilityStatement, or a TerminologyCapabilities with ?mode=terminology.
See the API Reference for the full parameter list. A parameter an operation does not take, or one sent twice, is refused with a 400 that names it; property on $lookup is the one that repeats. See parameters.

Look up a code

property is repeatable, up to 100 times, and narrows what comes back. Leave it off and you get everything the code system knows about the code.
On $lookup, displayLanguage defaults to en-US, which keeps responses small. The upstream code system otherwise returns around thirty translations per code, which is roughly fourteen times the payload for content most callers discard. $expand has no such default: it sends displayLanguage only when you supply one. RxNorm and ICD-10-CM are held in English only: ask either for another language and the answer is in English, with a warning parameter on $lookup, and a message on $validate-code, saying so.

Three ways to pass a code

The validate operations accept a code in three shapes. The first works everywhere; the other two are complex FHIR datatypes, which the operations framework only permits in a POST body — sent in a query string they return a 400 explaining exactly that. 1. system + code — two separate parameters, GET or POST. The form used in every example above. 2. coding — the two bundled as one object, POST only. In a real FHIR record a code does not live as two loose fields; it lives as a Coding. This form lets you pass it without taking it apart first:
On CodeSystem/$validate-code the coding’s system stands in for url — the code system is the thing being asked about. 3. codeableConcept — one concept, several labels, POST only. A CodeableConcept is a single idea coded more than one way, and it validates as a member if any of its codings is. This is the shape real clinical data actually has: exports routinely carry a vendor-local coding alongside a standard one on the same element. The vendor label cannot decide membership — no terminology server has ever heard of it — but the standard label beside it can, and this form means you never have to guess which label to send:
When no coding matches, the message names each label’s own reason — an unrecognised vendor system and a real code that simply is not in the set are different kinds of no. And if the request could not actually be answered — the value set does not exist, a shared parameter is invalid, a code system was unreachable — the whole request answers with that error rather than a false. “We could not ask” is never reported as “no”, and the concept form always agrees with what the plain system + code form would say. Two details of how codings are read:
  • Per-coding membership ignores each coding’s display. A display mismatch fails validation even when the code is a member, and a vendor-worded label must not veto a right code. Display checking is available on the single-coding form, where the response tells you the code was valid and only the label differed.
  • coding.version is honoured on the CodeSystem operations and refused on ValueSet validation (which always uses the current code-system version) rather than being silently ignored.
Value sets and code systems are addressed by canonical URL only (the url parameter). Instance-level operation paths (/ValueSet/{id}/$validate-code) are not offered: this service hosts published definitions whose canonical URLs are their identity, so there are no server-assigned ids to address.

Validating against a hosted value set

Some published value sets are defined as a rule rather than a list — “every code in this system with such-and-such property” — and resolve to tens of thousands of members. This service hosts a number of those definitions and answers membership for them directly, so you can ask about a code without expanding anything:
GET /v1/fhir/r4/metadata?mode=terminology lists the value sets and code systems this service answers for. A canonical it does not host and cannot resolve upstream is refused with an OperationOutcome, never answered with a guess.
$expand is refused for rule-defined value sets whose expansion runs to many thousands of codes, and for the whole of LOINC. A truncated expansion looks complete, which is worse than a clear refusal, and $validate-code answers membership without materialising the set. The RxNorm value sets and the ICD-10-CM value sets are the exception: they are searched, so a filter is required and every answer states its total.

Expanding a value set

url is the only parameter accepted for a value set resolved from its own source: filter, count and offset are refused there with a 400 naming them, because that source answers a whole expansion or nothing. Expand the set and filter the result. On value sets this service hosts, and on the RxNorm and ICD-10-CM value sets, those three narrow the expansion. filter matches the start of words. Each word you send must be the start of a word in one name of a code, and every word must match. Case and accents are ignored, and a number written against its unit, such as 20mg, is read as two words. A fragment from the middle of a word does not match: hemo finds hemoglobin, globin does not. TerminologyCapabilities states the same rule as expansion.textFilter. displayLanguage, includeDesignations and activeOnly are refused on $expand everywhere, also with a 400 naming them. This service does not narrow an expansion by language, designation or status. The whole of LOINC (http://loinc.org?fhir_vs, the value set url defaults to when it is left out) is not expanded: it answers 422. To ask whether a code is in LOINC, use $validate-code, which answers it against the whole of LOINC. To list codes, expand a LOINC group or answer list, for example http://loinc.org/vs/LG51070-7.
An empty expansion does not always mean the value set is empty. For a canonical inside a source’s own namespace, a set that does not exist can come back as a successful expansion with no members rather than as an error, so an empty result cannot be read as proof that the set exists and is empty. Use $validate-code when you need a decided answer about a specific code, and treat total: 0 as “nothing to show” rather than as a fact about the set.

Several operations in one request

POST a FHIR Bundle with type: batch to the service base. Entries are independent: one unknown code does not fail the others, and each carries its own status.
This is worth using for anything more than a handful of codes. Measured against the upstream, a batch of fifty lookups costs about 5 ms per code, against about 180 ms each when sent one at a time. Transaction bundles are not accepted: these operations do not write anything, so all-or-nothing would only turn one bad code into a wholly failed request. A batch carries at most 100 entries in at most 1 MiB, and has 60 seconds: an entry still running then answers 504 inside the batch’s 200. A Parameters body sent to one operation has the same 1 MiB limit. See limits.

Responses and errors

Success returns standard FHIR: a Parameters for $lookup and $validate-code, a ValueSet for $expand. Every error is a FHIR OperationOutcome, with issue.code telling you what kind of problem it is. The statuses every service shares, among them 401, 405, 406, 415, 429 and 500, are on Errors, limits and request ids, which also says when to send a 502, 503 or 504 again, including inside a batch. Two things worth knowing:
  • A code that is not in a value set is a successful 200 with "result": false, not an error.
  • result: false can also mean the display you sent does not match. If you pass display, it is checked against the code’s real display, and a mismatch returns false with the expected display alongside it. The code is fine; only the label differs. Vendor labels differ from published displays routinely, so do not read that as an invalid code.
We never pass an upstream error through unchanged. Upstream terminology servers are not consistently valid FHIR and their messages name their own internals, so errors are translated into outcomes of our own, with the upstream text kept as supporting diagnostics. Code systems we hold ourselves have no upstream to pass through; their errors are written here.

Code systems

Which code systems the service answers about, and why it declines the others, is on Code systems: each one’s URI, the names and OIDs it is recognised by, its publisher, its licence and whether it is served.

RxNorm

Most code systems here are resolved from their own source when you ask. RxNorm is different: we hold it, at a named release, and answer from our own copy. That changes two things you can see. Answers are fast and do not depend on anyone else being up. And every answer tells you which release it came from, so a result you got last month and a result you get today are comparable. Which release you are talking to is in TerminologyCapabilities:
RxNorm appears under codeSystem with a version, and the same value comes back on every $lookup. If a deployment has no release published yet, RxNorm is listed as not served with the reason, and requests answer 503 rather than pretending.

Looking up a concept

The system and url parameters default to LOINC, so an RxNorm request has to name RxNorm explicitly. Leave it off and you are asking a different question.
You get the concept’s code and system, its name as display, abstract (always false: every concept we hold is one you can use), the name again as a designation used preferredForLanguage, its other names as designation entries, and a property for each of:
  • RxNorm’s own attributes, under RxNorm’s own names — term type, strength, quantity, dispensable-form flags, NDC codes and the rest. We do not rename them, so what you read here matches what you read in RxNorm’s documentation.
  • inactive, FHIR’s standard property, which is false for everything we hold. The content set we hold contains active concepts only. RxNorm’s own suppressibility flag comes back separately under its own name, because one of its values means “unquantified but current”, and reporting such a concept as inactive would tell you it had been retired.
  • each relationship the concept has, keyed by RxNorm’s name for it and carrying the related concept as a Coding. So a brand drug’s relationship to its clinical drug comes back with the other concept’s code and display, not just a label you would have to look up again.
Narrow what comes back with property= — repeatable up to 100 times, and a name we have no value for is simply absent rather than an error.

What we hold, and what that means for a “no”

We hold RxNorm Current Prescribable Content, the subset NLM publishes for currently prescribable drugs. As NLM defines it, that is active normalized names, and it excludes obsolete or suppressed data, drugs that are exclusively non-US, and drugs for exclusive veterinary use.
A code we do not hold comes back as 404, not as a negative result. We hold a subset, so we cannot tell a code that never existed from one that exists outside the subset — and answering “not valid” for the second would be a guess. The outcome says so.
This is also why membership questions use our own value set for the content we hold rather than a canonical meaning all of RxNorm:
Membership of that set is something we know, so result is a real answer either way.

Asking for a particular release

version is honoured when it names the release we are serving, in either the FHIR form (the release date as it appears in the source’s file names) or the source’s own form. Any other value is refused by name: we hold one release at a time, and quietly answering about a different one would answer a question you did not ask.

Searching by name

ValueSet/$expand with a filter searches the release we hold by name. Two value sets can be searched:
  • A concept matches when every word of the filter starts a word of its name or of one of its RxNorm synonyms, all within that one name.
  • Matches come ranked, best first. A name starting with the first word you typed comes before one that only contains it, and a match on a synonym alone comes last. Within those, single-ingredient drugs come before combinations, clinical drugs before branded ones, and shorter names first. The order is stable, so offset pages through it.
  • Each code’s display is its RxNorm name, the same display $lookup returns for it, even when a synonym is what matched. Send it on with the code wherever a display is checked.
  • The first 20 come back when count is not sent, and count takes up to 1,000. expansion.total is the exact number of matches, and count=0 answers it alone.
  • A filter needs at least 3 letters or digits. Without one, or with fewer, the value set is not listed whole: the answer is 422 with issue.code too-costly.
  • The answer names the release. The value set’s version and the expansion’s used-codesystem parameter both carry it.
A filter that matches nothing is a 200 with total: 0. $validate-code against the products value set answers false for a concept we hold under another term type, and says which one.

ICD-10-CM

We hold ICD-10-CM too, at a named release, and answer from our own copy. A release is every code of its version, as the National Center for Health Statistics publishes it, so here a “no” is a real answer: a code we do not hold is not an ICD-10-CM code in that version.

Codes are written dotted

An ICD-10-CM code is written as ICD-10-CM writes it: in capitals, with a dot after the third character, such as E11.9. A code sent another way, E119 or e11.9, is not found, and the answer names the form ICD-10-CM writes when that form is a code, so you can correct the record you took it from. We never rewrite the code you sent.
The answer carries the code and the system, the release’s version, the code’s display (its long title), abstract, the display again as a designation used preferredForLanguage, the code’s short title where it has a different one, and one property, notSelectable (FHIR’s own concept property): false for a code valid for submission, true for a header.

Short titles

ICD-10-CM gives every code a short title of up to 60 characters beside its long one. S72.001A’s long title is “Fracture of unspecified part of neck of right femur, initial encounter for closed fracture”, and its short title “Fracture of unsp part of neck of right femur, init”. $lookup answers the short title as a designation whose use is synonym in http://fhir.org/packages/fhir.tx.support/CodeSystem/designation-usage, the use HL7’s reference terminology server, tx.fhir.org, gives it:
A code whose two titles are the same, such as E11, has no second designation. Both $validate-code operations take either title as a matching display.

Headers

A header is a code with codes below it, such as E11, the category above E11.9. ICD-10-CM lists it as a code, so CodeSystem/$validate-code answers result: true for it, with a message saying it is a header and not valid for submission. A header carries abstract: true, in $lookup and in $expand alike.

Valid for submission

Two value sets cover the release:
To check a code for a claim, ask ValueSet/$validate-code with url=https://haau3.com/fhir/ValueSet/icd-10-cm-valid-for-submission. A header answers false there. CodeSystem/$validate-code says only whether a code is an ICD-10-CM code.

What a “no” means

A code we do not hold is not an ICD-10-CM code in the version we serve, and the answer says so, naming that version: $validate-code answers result: false, and $lookup answers 404 with issue.code not-found. This is unlike RxNorm, where we hold a subset and a code outside it may still be valid.

Asking for a particular version

A release’s version is its effective date, 2026-10-01 for the codes that took effect on October 1, 2026, and the same value comes back on every $lookup. We hold one release at a time, and a new one becomes current on its effective date. version is honoured when it names the release we serve; any other value is refused with a 400 naming it.

Searching ICD-10-CM

ValueSet/$expand with a filter searches either value set by name and by code:
  • A code matches when every word of the filter starts a word of its display, or of its code, all within that one name. E11 finds E11 and the codes below it. The filter’s words split a letter from digits, so 11.9 also finds F11.9.
  • Matches come ranked, best first. Codes that match come first, in code order. Then a display starting with the first word you typed, then one that only contains it, shorter displays first. The order is stable, so offset pages through it.
  • The first 20 come back when count is not sent, and count takes up to 1,000. expansion.total is the exact number of matches, and count=0 answers it alone.
  • A filter needs at least 3 letters or digits, or the answer is 422 with issue.code too-costly.
  • The answer names the release, in the value set’s version and the expansion’s used-codesystem parameter.
This material contains content from LOINC (http://loinc.org). LOINC is copyright © Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the license at http://loinc.org/license. LOINC® is a registered United States trademark of Regenstrief Institute, Inc. Some individual codes carry a further copyright held by whoever created the underlying instrument, most often a survey or assessment. Where one does, it comes back with the code as an EXTERNAL_COPYRIGHT_NOTICE property, and you must keep it with the code wherever you use it. This product uses publicly available data courtesy of the U.S. National Library of Medicine (NLM), National Institutes of Health, Department of Health and Human Services; NLM is not responsible for the product and does not endorse or recommend this or any other product. This product uses ICD-10-CM, developed by the National Center for Health Statistics (NCHS) of the Centers for Disease Control and Prevention (CDC). ICD-10-CM is available from CDC at no charge at https://www.cdc.gov/nchs/icd/icd-10-cm/index.html. The use of ICD-10-CM here does not imply endorsement by CDC, HHS or the United States Government of haau3 or its products and services. Reference to specific commercial products, manufacturers, companies, or trademarks does not constitute its endorsement or recommendation by the U.S. Government, Department of Health and Human Services, or Centers for Disease Control and Prevention. Code systems we hold locally are served from a dated release, named in the version of every answer and in TerminologyCapabilities. A held release may not reflect the most current data available from its publisher. FHIR® is the registered trademark of Health Level Seven International and its use here does not constitute endorsement by HL7. Full terms are at platform.haau3.com/terms. For your first call, start with the Quickstart.