NDC → RxNorm → SNOMED: Crosswalk Any Drug Code in One API Call
Stop stitching RxNav calls to NDC flat files. Here's how to crosswalk a drug code across NDC, RxNorm, and SNOMED in a single API call — with working TypeScript.

You have an NDC from a pharmacy claim. The clinical decision support engine downstream speaks RxNorm. The patient summary you're building wants SNOMED CT. These are three different code systems maintained by three different organizations, and getting from one to another is the kind of plumbing that quietly eats a sprint.
The usual approach is to wire up the NLM's RxNav API for the RxCUI, parse a quarterly NDC flat file to fill the gaps, and then hand-maintain a UMLS-derived table to reach SNOMED. Three integrations, three update cadences, three places for the mapping to drift.
FHIRfly stores these code systems as a linked graph, not 22 isolated lookups. The NDC → RxCUI → SNOMED chain is pre-computed and returned inline. This tutorial shows how to walk it in either direction with the TypeScript SDK and the REST API. You'll have working code in a few minutes.
The Drug Code Graph
Three identifiers, three jobs:
- NDC (National Drug Code) — the FDA's identifier for a specific drug package on the US market. Manufacturer-specific. The code on the bottle.
- RxCUI (RxNorm Concept Unique Identifier) — the NLM's normalized drug concept. One RxCUI ("atorvastatin 40 MG Oral Tablet") maps to hundreds of NDCs from different labelers.
- SNOMED CT — the clinical terminology used in problem lists, summaries, and FHIR
Medicationresources internationally.
The relationships are many-to-one and many-to-many: many NDCs collapse to one RxCUI; an RxCUI links out to a SNOMED concept. FHIRfly derives the SNOMED mapping through the RxCUI chain (UMLS), so every NDC carries its normalized concept and its clinical concept without a second round trip.
Setup
npm install @fhirfly-io/terminology
import { Fhirfly } from "@fhirfly-io/terminology";
const client = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY! });
Get a free API key from the FHIRfly dashboard — 10,000 requests/month, no credit card.
NDC → RxNorm + SNOMED in One Call
Start with an NDC and request the standard shape. The response carries the RxCUI and the derived SNOMED mapping inline:
const { data } = await client.ndc.lookup("70518-4321", { shape: "standard" });
console.log(data.generic_name);
// "Atorvastatin calcium"
console.log(data.rxcui);
// ["617311"] ← the normalized RxNorm concept
console.log(data.snomed);
// [
// {
// concept_id: "1145421000",
// display: "Atorvastatin (as atorvastatin calcium) 40 mg oral tablet",
// map_type: "equivalent",
// map_source: "derived-rxnorm"
// }
// ]
One lookup, three code systems. The map_source: "derived-rxnorm" tells you the SNOMED concept was reached through the RxCUI chain — useful provenance when you're auditing how a clinical code ended up on a record.
You also get a ready-to-use FHIR coding for the NDC itself:
console.log(data.fhir_coding);
// {
// system: "http://hl7.org/fhir/sid/ndc",
// code: "70518-4321",
// display: "Atorvastatin calcium"
// }
RxNorm → NDC: Every Package for a Drug
The graph runs both ways. Hand the RxCUI back to the RxNorm endpoint and you get every NDC that normalizes to that concept — useful for "find all packages of this drug" or expanding a formulary entry into billable codes:
const { data } = await client.rxnorm.lookup("617311", { shape: "standard" });
console.log(data.name, data.tty);
// "atorvastatin 40 MG Oral Tablet" "SCD"
console.log(data.ndcs.length);
// 365
console.log(data.ndcs.slice(0, 3));
// ["00093505810", "00093505898", "00378212177"]
That single RxCUI fans back out to 365 NDC packages from different labelers — the reverse of the collapse you saw in the first call.
A Note on Term Types
RxNorm concepts have a term type (tty) that determines which fields are populated. This trips people up, so it's worth being explicit:
- Drug concepts —
SCD(clinical drug),SBD(branded drug) — carryndcs. That's where the package mappings live. - Brand and ingredient concepts —
BN(brand name),IN(ingredient) — carrybrand_namesandingredients.
So to get the brand and active ingredient, walk to the brand concept:
const { data } = await client.rxnorm.lookup("153165", { shape: "standard" });
console.log(data.name, data.tty);
// "Lipitor" "BN"
console.log(data.brand_names);
// ["Lipitor"]
console.log(data.ingredients);
// [{ rxcui: "83367", name: "atorvastatin", tty: "IN" }]
If you query a drug concept and find brand_names empty, you're at the wrong altitude in the graph — follow the ingredient's RxCUI, not the drug's.
SNOMED → Clinical Concept
The SNOMED concept_id from the first call is a real, resolvable code. Look it up to get the fully specified name and preferred term for a problem list or Medication resource:
const { data } = await client.snomed.lookup("1145421000");
console.log(data.preferred_term);
// "Atorvastatin (as atorvastatin calcium) 40 mg oral tablet"
console.log(data.fsn);
// "Product containing precisely atorvastatin (as atorvastatin calcium)
// 40 milligram/1 each conventional release oral tablet (clinical drug)"
console.log(data.semantic_tag);
// "clinical drug"
That completes the chain: NDC 70518-4321 → RxCUI 617311 → SNOMED 1145421000, with display strings you can render at every hop.
Putting It Together: A Crosswalk Helper
In practice you want one function that takes any NDC and returns the whole picture. Here's a typed helper that walks the graph and degrades gracefully when a hop is missing:
import { Fhirfly } from "@fhirfly-io/terminology";
const client = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY! });
interface DrugCrosswalk {
ndc: string;
name: string | null;
rxcui: string | null;
snomed: { code: string; display?: string } | null;
}
async function crosswalkNdc(ndc: string): Promise<DrugCrosswalk | null> {
try {
const { data } = await client.ndc.lookup(ndc, { shape: "standard" });
return {
ndc: data.ndc,
name: data.generic_name ?? data.brand_name ?? null,
rxcui: data.rxcui[0] ?? null,
snomed: data.snomed?.[0]
? { code: data.snomed[0].concept_id, display: data.snomed[0].display }
: null,
};
} catch (err) {
// not_found / invalid NDC — return null and let the caller decide
return null;
}
}
const result = await crosswalkNdc("70518-4321");
// {
// ndc: "70518-4321",
// name: "Atorvastatin calcium",
// rxcui: "617311",
// snomed: { code: "1145421000", display: "Atorvastatin ... 40 mg oral tablet" }
// }
No second service, no local mapping table — the RxCUI and SNOMED concept come back on the first response.
Batch Crosswalking a Claims File
Enriching a pharmacy claims export? Don't loop the single-lookup endpoint. The batch endpoint takes up to 500 NDCs per request and returns the same standard shape — RxCUI and SNOMED included — with a per-item status so one bad code doesn't sink the batch:
const response = await client.ndc.lookupMany(
["70518-4321", "70518-3783", "70518-3848", "0000-0000-00"],
{ shape: "standard" }
);
for (const item of response.results) {
if (item.status === "ok") {
console.log(`${item.input} → RxCUI ${item.data.rxcui[0]}`);
} else {
console.log(`${item.input} → ${item.status}`);
}
}
// "70518-4321 → RxCUI 617311"
// "70518-3783 → RxCUI 617310"
// "70518-3848 → RxCUI 259255"
// "0000-0000-00 → not_found"
Three NDCs from the same labeler, three different RxCUIs — that's the normalization doing real work: each strength collapses to its own clinical concept.
REST API (Without the SDK)
The crosswalk is just response fields, so any language works. Read rxcui and snomed straight off the standard shape:
# NDC → RxCUI + SNOMED
curl -H "x-api-key: $FHIRFLY_API_KEY" \
"https://api.fhirfly.io/v1/ndc/70518-4321?shape=standard"
# RxCUI → NDCs (reverse)
curl -H "x-api-key: $FHIRFLY_API_KEY" \
"https://api.fhirfly.io/v1/rxnorm/617311?shape=standard"
# Resolve the SNOMED concept
curl -H "x-api-key: $FHIRFLY_API_KEY" \
"https://api.fhirfly.io/v1/snomed/1145421000"
# Batch NDC crosswalk
curl -X POST -H "x-api-key: $FHIRFLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"codes": ["70518-4321", "70518-3783"]}' \
"https://api.fhirfly.io/v1/ndc/_batch?shape=standard"
Why This Is One Call, Not Five
The point isn't that any single lookup is hard — it's that the joins are. RxNav gives you the RxCUI but not the FDA package detail. The FDA NDC file gives you the package but not the SNOMED concept. The UMLS gives you the SNOMED mapping but ships as a multi-gigabyte relational dump you have to load and refresh.
FHIRfly runs those joins as an enrichment pipeline and denormalizes the result onto each record. The map_source: "derived-rxnorm" field keeps the derivation honest — you always know the SNOMED concept came through the RxCUI chain rather than a direct assertion. And the source data is public domain:
const { meta } = await client.ndc.lookup("70518-4321", { shape: "standard" });
console.log(meta.legal);
// {
// license: "public_domain",
// attribution_required: false,
// source_name: "FDA NDC Directory",
// citation: "FDA NDC Directory. Accessed 2026-06-11 via FHIRfly."
// }
Key Takeaways
- One lookup, three code systems — the
standardNDC shape returns the RxCUI and a derived SNOMED concept inline - The graph runs both ways — an RxCUI fans back out to every NDC package that normalizes to it
- Mind the term type —
ndcslive on drug concepts (SCD/SBD);brand_namesandingredientslive on brand and ingredient concepts (BN/IN) - Provenance is explicit —
map_source: "derived-rxnorm"tells you how the SNOMED concept was reached - Batch up to 500 NDCs per request with per-item status for claims enrichment
- Public domain source data — no licensing restrictions on what you build
Next Steps
- Get a free API key from the FHIRfly dashboard
- Install the SDK:
npm install @fhirfly-io/terminology - Run
crosswalkNdc()against your own NDC codes - See the NDC, RxNorm, and SNOMED API docs for the full parameter reference
Build it on real terminology
Try any endpoint live — no sign-up required.
