---
{
  "title": "Building FHIR Provider Directory for Medicare Plan Finder",
  "description": "CMS requires Medicare Advantage plans to publish a PDex Plan-Net FHIR provider directory. With bulk $export and _typeFilter, getting the right data out takes one call, and the rest is ordinary code.",
  "date": "2026-07-06",
  "author": "Akim Khalitov, Gleb Markin",
  "tags": [
    "Compliance",
    "Integrations",
    "Payerbox"
  ],
  "reading-time": "7 min read",
  "tldr": "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.",
  "utm-campaign": "feature",
  "utm-content": "provider-directory",
  "hide-comments": true
}
---

If you run a Medicare Advantage plan, CMS now requires you to publish a
[provider directory](https://www.health-samurai.io/articles/mpf-provider-directory-medicare-advantage-cms-4208-f2)
listing which providers and facilities are in-network for each plan, so
[Medicare Plan Finder](https://www.medicare.gov/plan-compare/) (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](https://www.health-samurai.io/articles/understanding-the-cms-0057-f-interoperability-and-prior-authorization-final-rule).

CMS accepts two formats, a flat JSON file or FHIR; we build the FHIR one. It follows
[PDex Plan-Net](https://hl7.org/fhir/us/davinci-pdex-plan-net/), the Da Vinci IG that
standardizes a plan's network across FHIR profiles on
[InsurancePlan](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-InsurancePlan.html),
[Organization](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-Organization.html)
(as both network and facility),
[Practitioner](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-Practitioner.html),
[PractitionerRole](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-PractitionerRole.html),
[Location](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-Location.html), and
[OrganizationAffiliation](https://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition-plannet-OrganizationAffiliation.html),
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](https://www.cms.gov/files/zip/mpf-ma-provider-directory-technical-guide-02182026-final-zip.zip).
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:

```mermaid
flowchart LR
    A["1 · Extract<br/>$export + _typeFilter<br/>(narrows parents)"] --> B["2 · Filter<br/>resolve refs,<br/>keep children"]
    B --> C["3 · Bundle<br/>FHIR + index.json"]
    C --> D["4 · Publish<br/>cloud storage"]
    D --> E["CMS crawls<br/>daily"]
```

*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](https://www.health-samurai.io/articles/unified-fhir-async-operations-pattern)
  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](https://hl7.org/fhir/uv/bulkdata/) 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:

<div class="narrow" style="display: flex; justify-content: center">

```mermaid
flowchart TD
    IP["InsurancePlan"] -->|network| NET["Organization<br/>(Network, type=ntwk)"]
    PR["PractitionerRole"] -->|network| NET
    PR -->|practitioner| PRAC["Practitioner"]
    PR -->|location| LOC["Location"]
    OA["OrganizationAffiliation"] -->|network| NET
    OA -->|organization| FAC["Organization<br/>(Facility, type=fac)"]
    OA -->|location| LOC
```

</div>

*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](https://hl7.org/fhir/R4/bundle.html)
(`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:

```json
{
  "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:

```http
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](https://www.health-samurai.io/cms-0057-f), 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](https://www.health-samurai.io/company#contact-form) to get started.

## References

- [CMS-4208-F2 final rule](https://www.federalregister.gov/documents/2025/09/19/2025-18236/medicare-and-medicaid-programs-contract-year-2026-policy-and-technical-changes-to-the-medicare)
- [The MPF provider directory requirement, explained](https://www.health-samurai.io/articles/mpf-provider-directory-medicare-advantage-cms-4208-f2)
- [CMS MA Provider Directory Technical Implementation Guide](https://www.cms.gov/files/zip/mpf-ma-provider-directory-technical-guide-02182026-final-zip.zip)
- [Da Vinci PDex Plan-Net IG](https://hl7.org/fhir/us/davinci-pdex-plan-net/)
- [FHIR Bulk Data Access](https://hl7.org/fhir/uv/bulkdata/)
