Validating FHIR Code Systems for the CMS Patient Access API Deadline
How to validate NDC, ICD-10, LOINC, and NPI codes in your Patient Access API before the March 31 CMS reporting deadline.

The CMS Patient Access API metrics reporting deadline is March 31, 2026. If you're at a payer organization building or maintaining a FHIR-based Patient Access API under CMS-0057-F, you're likely focused on getting your usage numbers reported. But there's a harder problem behind the metrics: are the clinical codes in your FHIR resources actually valid?
Serving an ExplanationOfBenefit with a retired ICD-10 code, an inactive NDC, or a deactivated NPI doesn't just look bad — it undermines the entire point of the mandate. This post walks through how to validate every code system in your Patient Access API responses using the FHIRfly Terminology SDK.
What CMS Actually Requires
The CMS Interoperability and Prior Authorization final rule (CMS-0057-F) requires impacted payers — Medicare Advantage organizations, state Medicaid/CHIP programs, managed care plans, and QHP issuers on the FFEs — to expose patient data through a FHIR R4 API following the CARIN IG for Blue Button.
The core FHIR resource is the ExplanationOfBenefit (EOB), which carries claims and encounter data referencing multiple code systems:
| Code System | Used In | Purpose |
|---|
| NDC | item.productOrService, item.detail | Drug products in pharmacy/medical claims |
| ICD-10 | diagnosis.diagnosisCodeableConcept | Diagnosis and procedure codes |
| RxNorm | Drug references, formulary data | Standardized drug terminology |
| LOINC | Clinical data, lab results | Laboratory and observation codes |
| NPI | provider, careTeam | Provider identification |
| CVX | Immunization records | Vaccine codes |
The March 31 reporting deadline requires payers to submit two metrics for CY2025: the total number of unique patients whose data was transferred via the API, and the total transferred more than once. But CMS expects the data itself to be accurate — and that starts with valid codes.
The Code Validation Problem
Claims data is messy. It passes through multiple systems before landing in your FHIR server, and each hop introduces opportunities for code quality issues:
- Stale NDCs: The FDA retires drug codes regularly. A valid code from 2023 might be inactive today.
- Retired ICD-10 codes: CMS updates ICD-10-CM/PCS annually. Codes from prior fiscal years may no longer be valid.
- Deactivated NPIs: Providers leave practice, organizations close. NPI records get deactivated.
- Missing display text: The CARIN IG notes that payers MAY provide concept text or coding display — but getting that text right matters for consumer-facing apps.
You need a reliable way to validate these codes before serving them through your API. Here's how to do it, code system by code system.
Validating Drug Codes (NDC + RxNorm)
Pharmacy claims use NDC codes in item.productOrService. The FHIRfly SDK's ndc endpoint validates codes and returns current product data.
Single NDC Validation
import { Fhirfly, NotFoundError } from "@fhirfly-io/terminology";
const client = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY });
async function validateNdc(code: string) {
try {
const result = await client.ndc.lookup(code, { shape: "standard" });
return {
valid: true,
active: result.data.is_active,
display: result.data.brand_name ?? result.data.generic_name,
dosageForm: result.data.dosage_form,
rxcuis: result.data.rxcui,
};
} catch (error) {
if (error instanceof NotFoundError) {
return { valid: false, active: false, display: null };
}
throw error;
}
}
Batch NDC Validation
When processing EOB bundles, you'll have many NDCs to check. The SDK supports batches of up to 500 codes in a single call:
async function validateNdcBatch(codes: string[]) {
const result = await client.ndc.lookupMany(codes, { shape: "standard" });
const report = {
total: result.count,
valid: 0,
invalid: 0,
inactive: 0,
issues: [] as Array<{ code: string; issue: string }>,
};
for (const item of result.results) {
if (item.status === "not_found" || item.status === "invalid") {
report.invalid++;
report.issues.push({ code: item.input, issue: item.status });
} else if (item.data && !item.data.is_active) {
report.inactive++;
report.issues.push({ code: item.input, issue: "inactive" });
} else {
report.valid++;
}
}
return report;
}
Cross-Referencing with RxNorm
NDC codes map to RxNorm concepts (RxCUIs). If your EOB includes both, you can cross-validate:
async function crossValidateDrugCodes(ndc: string, expectedRxcui: string) {
const ndcResult = await client.ndc.lookup(ndc, { shape: "standard" });
// Check if the NDC maps to the expected RxCUI
const rxcuiMatch = ndcResult.data.rxcui.includes(expectedRxcui);
if (!rxcuiMatch) {
// Look up the RxCUI to see what it actually maps to
const rxResult = await client.rxnorm.lookup(expectedRxcui, {
shape: "standard",
});
return {
match: false,
ndcDrug: ndcResult.data.brand_name ?? ndcResult.data.generic_name,
rxnormDrug: rxResult.data.name,
ndcRxcuis: ndcResult.data.rxcui,
};
}
return { match: true };
}
Validating Diagnosis Codes (ICD-10)
EOB resources carry diagnosis codes in the diagnosis array. Each entry has a diagnosisCodeableConcept with an ICD-10-CM code (or ICD-10-PCS for inpatient procedures). The SDK auto-detects CM vs PCS from the code format.
Validating Diagnosis Entries
async function validateDiagnoses(
diagnoses: Array<{ code: string; display?: string }>
) {
const codes = diagnoses.map((d) => d.code);
const result = await client.icd10.lookupMany(codes, { shape: "standard" });
const issues = [];
for (const item of result.results) {
if (item.status === "not_found") {
issues.push({
code: item.input,
issue: "Code not found in current ICD-10 code set",
});
continue;
}
if (item.data) {
// Check if it's a header code (not billable)
if (item.data.type === "cm" && !item.data.billable) {
issues.push({
code: item.input,
issue: `Non-billable header code. Use a more specific child code.`,
});
}
// Check display text accuracy
const source = diagnoses.find((d) => d.code === item.input);
if (source?.display && source.display !== item.data.display) {
issues.push({
code: item.input,
issue: `Display mismatch: "${source.display}" vs "${item.data.display}"`,
});
}
}
}
return issues;
}
Example: Validating a Common Diagnosis
const result = await client.icd10.lookup("E11.9", { shape: "standard" });
// result.data:
// {
// code: "E11.9",
// type: "cm",
// display: "Type 2 diabetes mellitus without complications",
// billable: true,
// chapter: "4",
// chapter_description: "Endocrine, nutritional and metabolic diseases"
// }
Validating Lab Results (LOINC)
If your Patient Access API includes clinical data — lab results, vital signs, or other observations — those records use LOINC codes. Validating LOINC codes ensures your Observation resources reference active, correctly-named tests.
async function validateLabCodes(
observations: Array<{ loincCode: string; displayName?: string }>
) {
const codes = observations.map((o) => o.loincCode);
const result = await client.loinc.lookupMany(codes, { shape: "standard" });
const issues = [];
for (const item of result.results) {
if (item.status === "not_found") {
issues.push({
code: item.input,
issue: "LOINC code not found",
});
continue;
}
if (item.data) {
// Flag deprecated codes
if (item.data.status !== "ACTIVE") {
issues.push({
code: item.input,
issue: `Code status is ${item.data.status} — consider using a replacement`,
});
}
// Verify display name
const obs = observations.find((o) => o.loincCode === item.input);
if (
obs?.displayName &&
obs.displayName !== item.data.display_name &&
obs.displayName !== item.data.long_name
) {
issues.push({
code: item.input,
issue: `Display name mismatch: "${obs.displayName}" vs "${item.data.display_name}"`,
});
}
}
}
return issues;
}
Validating Provider Identifiers (NPI)
EOB resources reference providers in provider and careTeam elements. A deactivated NPI in your API response means the provider record may be stale.
async function validateProviderNpis(npis: string[]) {
const result = await client.npi.lookupMany(npis, { shape: "standard" });
const issues = [];
for (const item of result.results) {
if (item.status === "not_found") {
issues.push({
npi: item.input,
issue: "NPI not found in NPPES",
});
continue;
}
if (item.data && !item.data.is_active) {
issues.push({
npi: item.input,
issue: "NPI is deactivated",
name: item.data.organization_name ??
`${item.data.name?.last}, ${item.data.name?.first}`,
});
}
}
return issues;
}
Putting It Together: A Batch Validation Pipeline
Here's a complete validation function that checks all code systems in a FHIR ExplanationOfBenefit before your Patient Access API serves it:
import {
Fhirfly,
NotFoundError,
RateLimitError,
} from "@fhirfly-io/terminology";
const client = new Fhirfly({
apiKey: process.env.FHIRFLY_API_KEY,
maxRetries: 3,
});
interface ValidationReport {
eobId: string;
timestamp: string;
ndcIssues: Array<{ code: string; issue: string }>;
diagnosisIssues: Array<{ code: string; issue: string }>;
loincIssues: Array<{ code: string; issue: string }>;
npiIssues: Array<{ npi: string; issue: string }>;
isClean: boolean;
}
async function validateEob(eob: {
id: string;
items: Array<{ ndcCode?: string; rxcui?: string }>;
diagnoses: Array<{ code: string; display?: string }>;
labCodes: Array<{ loincCode: string; displayName?: string }>;
providerNpis: string[];
}): Promise<ValidationReport> {
// Extract NDC codes from line items
const ndcCodes = eob.items
.map((item) => item.ndcCode)
.filter((c): c is string => !!c);
// Run all validations concurrently
const [ndcReport, diagnosisIssues, loincIssues, npiIssues] =
await Promise.all([
ndcCodes.length > 0
? validateNdcBatch(ndcCodes)
: Promise.resolve({ issues: [] }),
eob.diagnoses.length > 0
? validateDiagnoses(eob.diagnoses)
: Promise.resolve([]),
eob.labCodes.length > 0
? validateLabCodes(eob.labCodes)
: Promise.resolve([]),
eob.providerNpis.length > 0
? validateProviderNpis(eob.providerNpis)
: Promise.resolve([]),
]);
const report: ValidationReport = {
eobId: eob.id,
timestamp: new Date().toISOString(),
ndcIssues: ndcReport.issues,
diagnosisIssues: diagnosisIssues,
loincIssues: loincIssues,
npiIssues: npiIssues,
isClean: false,
};
report.isClean =
report.ndcIssues.length === 0 &&
report.diagnosisIssues.length === 0 &&
report.loincIssues.length === 0 &&
report.npiIssues.length === 0;
return report;
}
This runs all validations concurrently — NDC, ICD-10, LOINC, and NPI checks happen in parallel, so the total latency is the slowest single call rather than the sum of all calls. For a typical EOB with 5-10 line items, expect validation to complete in under 200ms.
Key Takeaways
-
Validate before you serve. Running code validation against current reference data catches stale codes, incorrect display text, and deactivated identifiers before they reach consumer apps.
-
Batch where possible. The SDK supports batch lookups (up to 500 NDCs, 100 for other code systems) — use them to validate entire EOB bundles efficiently.
-
Check more than existence. A valid code isn't enough. Check is_active for NDCs, billable for ICD-10, status for LOINC, and is_active for NPIs.
-
Cross-validate across systems. NDC-to-RxCUI mappings should be consistent. If your EOB carries both, verify they agree.
-
Run concurrently. Code system validations are independent — run them in parallel with Promise.all to minimize latency.
The March 31 deadline is about metrics reporting, but the real test is whether your Patient Access API serves accurate, complete clinical data. Validating your code systems is the foundation.
This content is for informational purposes and does not constitute legal or compliance advice. Consult your compliance team regarding CMS-0057-F requirements.