|
7 min read

Building FHIR Provider Directory for Medicare Plan Finder

Article Summary

CMS now requires every Medicare Advantage plan to publish a provider directory for Medicare Plan Finder, which crawls it daily as published files, not a live API. CMS allows two formats; this takes the FHIR one: a bulk $export pulls the in-network resources, a streaming pass keeps only the records they reference, and an atomic publish serves the resulting FHIR Bundles, plus an index manifest, through a crawler-friendly endpoint.

Summarize this article with:
ChatGPTPerplexityClaudeGrok

If you run a Medicare Advantage plan, CMS now requires you to publish a provider directory listing which providers and facilities are in-network for each plan, so Medicare Plan Finder (MPF) can answer the question shoppers actually ask: is my provider covered? That question is MA-specific: an MA plan covers a contracted network, and since MA is federally funded Medicare run by private insurers, CMS gets to dictate how the directory is published. The directory has to be machine-readable, refreshed within 30 days of any change, and attested to once a year. It's part of CMS's broader push toward FHIR-based interoperability.

CMS accepts two formats, a flat JSON file or FHIR; we build the FHIR one. It follows PDex Plan-Net, the Da Vinci IG that standardizes a plan's network across FHIR profiles on InsurancePlan, Organization (as both network and facility), Practitioner, PractitionerRole, Location, and OrganizationAffiliation, and the references between them. CMS doesn't query your server per shopper: its crawler ingests your published files once a day and validates them, following its technical implementation guide. Fail validation, miss the annual attestation, or trip CMS's data-quality threshold, and CMS suppresses your directory from MPF, generally restoring it the day after you resolve the issue. While it's suppressed, shoppers comparing plans can't see your network, so keeping the directory valid on every run matters.

The rest of this article walks that pipeline, stage by stage.

How it works

Your data already lives in Payerbox. The pipeline turns it into the files CMS crawls, on a schedule. Four stages:

1 · Extract$export + _typeFilter(narrows parents) 2 · Filterresolve refs,keep children 3 · BundleFHIR + index.json 4 · Publishcloud storage CMS crawlsdaily

The daily run, end to end: extract from Payerbox, filter to the in-network subset, bundle as FHIR, then publish the files CMS crawls.

A scheduled job runs the whole thing each morning, ahead of CMS's daily crawl.

Everything after extraction is plain code over NDJSON, so it runs and tests against a sample export, no live FHIR server needed.

Getting the data out

There are two ways to get the resources out of Payerbox. The pipeline reads through a common source interface, so either could feed it; we use bulk $export.

  • The FHIR REST API. Page through with ordinary search queries. That's a fine fit for a small, targeted slice, but pulling whole resource types at scale means a lot of round-trips, and the data can shift between pages.
  • The FHIR bulk $export operation. This operation runs asynchronously and streams everything out as NDJSON in one pass, without the between-pages drift of paging the live API. It's built for this, so that's what we use.

$export is the standard FHIR Bulk Data Access operation, with _typeFilter to push selective filters into the export and +gzip on _outputFormat to keep the dump small. With _typeFilter, the server sends only the resources that matter:

GET /fhir/$export
  ?_outputFormat=application/fhir+ndjson+gzip
  &_type=InsurancePlan,Organization,Practitioner,PractitionerRole,Location,OrganizationAffiliation
  &_typeFilter=InsurancePlan?status=active&_profile=.../plannet-InsurancePlan&_id=...
  &_typeFilter=PractitionerRole?active=true&_profile=.../plannet-PractitionerRole&network=...
  &_typeFilter=OrganizationAffiliation?active=true&_profile=.../plannet-OrganizationAffiliation&network=...

Kicked off with the Prefer: respond-async request header, $export returns 202 Accepted with a Content-Location response header: the status URL you poll until the NDJSON files are ready. Plans, practitioner roles, and affiliations come back already narrowed to active, in-network, Plan-Net resources. The children they reference come in full, so a kept record never points at something dropped.

Filtering to the in-network subset

That $export was the first filter: _typeFilter narrows the parents on the FHIR server. A code-side pass then resolves the children: it walks the graph (plan → network → affiliated organizations and practitioners → their locations), keeps a child only when a kept parent references it, and drops the rest. That graph is the references PDex Plan-Net defines between the six resources:

network network practitioner location network organization location InsurancePlan Organization(Network, type=ntwk) PractitionerRole Practitioner Location OrganizationAffiliation Organization(Facility, type=fac)

The references between Plan-Net resources. _typeFilter narrows the parents (InsurancePlan, PractitionerRole, OrganizationAffiliation) on the FHIR server; the code pass then keeps the children they reference.

Bundling and publishing

Kept resources go into FHIR Bundles (type: collection), split per resource type into numbered files (PractitionerRole-001.json, -002.json, and so on); each resource keeps its source meta.lastUpdated, which the guide maps to each record's last-updated date. CMS expects the split: it requires a per-contract index file, a manifest of every file's URL the crawler reads first:

{
  "provider_urls": [
    "https://.../H9999/2026/InsurancePlan-001.json",
    "https://.../H9999/2026/PractitionerRole-001.json",
    "https://.../H9999/2026/PractitionerRole-002.json",
    "https://.../H9999/2026/Organization-001.json"
  ]
}

The split keeps each file a manageable size for the crawler; the pipeline rolls to the next numbered file once a bundle grows too large or passes an entry cap.

Publishing comes with one hard rule: a crawl must never land on a half-written directory. A file like Organization-042.json holds different resources from one run to the next, so overwriting it in place while the old manifest still points at it is a torn read. The publish is therefore an atomic swap: wipe the old files, upload the new bundles, then upload index.json last. That final upload is the single step that makes the new version live, so a crawler mid-publish sees yesterday's directory, today's, or a brief 404 during the swap, never a half-written blend. A run that fails partway raises an alert. There's no partial-resume: the next run just rebuilds the whole directory from scratch.

Serving the crawler

CMS recommends conditional requests, and the endpoint serves them. Each file carries an ETag (a fingerprint of its contents that changes when the file does) and a Last-Modified timestamp; the crawler stores them and, on its next visit, re-fetches a file only when its ETag has changed. Unchanged files come back as 304 Not Modified with no body:

GET /mpf-provider-directory/H9999/2026/Organization-042.json
If-None-Match: "a1b2c3"        # the ETag the crawler received on its last fetch
→ 304 Not Modified             # unchanged since then, so no body

On a directory of thousands of bundle files that barely change day to day, that's the gap between a heavy re-crawl and a cheap one. It also explains the atomic publish: the ETag must flip exactly when the contents change.

That endpoint proxies a private bucket rather than exposing the bucket itself, and needs no auth: the Plan-Net directory is meant to be openly accessible, with no client registration.

Building one yourself?

Building it is relatively modest engineering for anyone who works with FHIR servers regularly: a daily export-filter-bundle-publish job. But the care is in getting it right on every run, and the whole thing assumes correct Plan-Net data already lives in the FHIR server. Getting it there is a project of its own. Its data quality should be monitored continuously, validating the PDex Plan-Net resource set against CMS's MPF criteria.

This directory ships as an optional module of Payerbox, Health Samurai's platform for CMS payer requirements, which also delivers the CMS-0057-F APIs due in 2027.

Building a provider directory for Medicare Plan Finder? Talk to our team to get started.

References

Share this article
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.