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:
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
$exportoperation. 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:
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.





