# NPPES NPI Bulk Scraper — Every Provider by Specialty & State (`scrapersdelight/nppes-provider-scraper`) Actor

Pull an ENTIRE medical specialty from the official NPPES NPI Registry. The CMS API caps every query at 1,200 records; this Actor auto-splits by state and ZIP until each slice fits — 22,189 NY dentists, not 1,200. Name, phone, fax, practice address, license. Or paste NPI numbers to verify a list.

- **URL**: https://apify.com/scrapersdelight/nppes-provider-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Lead generation, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 per provider returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## NPPES NPI Bulk Scraper — Every Provider by Specialty & State

> **Export a whole medical specialty from the official [NPPES NPI Registry](https://npiregistry.cms.hhs.gov/) — not the first 1,200 rows of it.**
> One row per unique NPI: **npi, providerName, credential, gender, organizationName, authorizedOfficialName, authorizedOfficialTitle, authorizedOfficialPhone, matchedTaxonomy, primaryTaxonomy, primaryTaxonomyCode, licenseNumber, licenseState, phone, fax, practiceAddress1, practiceCity, practiceState, practiceZip, mailingAddress, otherNames, enumerationDate, lastUpdated, profileUrl.**
> Filter by specialty, state, ZIP prefix and provider type — or paste a list of NPI numbers and verify them instead.
> **No login. No API key. No CAPTCHA solving.** **$2.00 per 1,000 providers**, billed per unique NPI delivered.

**The CMS API hard-caps every query at 1,200 records. This Actor returned 22,189.**

That is not a slogan, it is a measured pair of numbers from the same search on the same day. `state=NY` + `taxonomy_description=Dentist` gives you **1,200** rows and then silently repeats them forever. This Actor split that one query into 146 smaller ones and returned **22,189 unique New York dentists in 278 API requests** (measured 2026-08-13; the same run on 2026-08-12 returned 22,187 — the registry moves daily). There are **379,654 dentists in the United States**. A single query gets you 0.3% of them.

**Click *Try for free* and press Start.** The Actor ships prefilled with New York dentists capped at 100 providers — that run costs **$0.20**, takes **6 API requests and 19 seconds** on the Apify platform, and needs zero edits.

***

### The 1,200 cap, and why it is the entire product

The free CMS NPPES API is generous in every way except one. `limit` is silently clamped to 200, `skip` is clamped at 1,000, and past that the API re-serves the same final page forever without ever telling you it truncated. The cap is **per query**, not per page:

| Query | Providers the API will hand over |
|---|---|
| `state=NY` + `Dentist` | **1,200**, then it repeats |
| ZIP `10001`, no specialty at all | **1,200**, then it repeats |
| ZIP `10016` + `Student`, individuals only | **1,200**, then it repeats |

This Actor treats 1,200 as a *signal*, not a limit. Any slice that comes back at exactly 1,200 is split and re-queried until every leaf fits underneath the cap:

```
specialty
  └── state                 (your list, or all 56 states + territories)
        └── ZIP-2   10*     (measured prefix list, per state)
              └── ZIP-3   100*
                    └── ZIP-4   1001*
                          └── ZIP-5   10016
                                └── provider type   NPI-1 / NPI-2
                                      └── surname prefix   last_name=sm*
```

A slice under the cap is already complete and is never expanded, so narrow pulls stay cheap while dense ones walk the tree. Measured on live runs:

| Run | Single-query ceiling | This Actor | Requests | Buckets | Wall clock |
|---|---|---|---|---|---|
| All NY dentists (2026-08-12) | 1,200 | **22,187** (21,499 practising in NY) | 278 | 146 | 1m 27s on Apify |
| All NY dentists (2026-08-13 re-run) | 1,200 | **22,189** (21,501 practising in NY) | 278 | 146 | — |
| ZIP 10016 + `Student` | 1,200 | **2,653** | 689 | — | 1m 15s |
| All WY dentists (2026-08-13) | 1,200 | 615 raw / **599** practising in WY | **4** | 1 | 7s |

Wyoming needs four requests because 615 is already under the cap — you are never charged for a split you did not need.

**Why we believe the coverage is real.** The New York pull was run down two *different* enumeration paths — once with the practice-address pin on and once off, which produces different cell boundaries, different split points and different raw totals (22,187 vs 23,467 rows). Both runs returned **exactly 21,499** providers whose practice address is in New York. Two independent walks converging on the same number is the strongest completeness evidence available without a bulk file to diff against.

***

### Quick start

1. Press **Try for free** and then **Start**. Nothing to fill in — it ships prefilled with `Dentist` + `NY` + a 100-provider cap.
2. Watch the log. Every slice prints its filters, its row count and whether it hit the 1,200 cap and had to split.
3. Open the **Dataset** tab and export **CSV / Excel / JSON**, or read it from the API.
4. Widen: pick more specialties from the dropdown, add states, and raise **Max providers**.

**Time to first rows: 6 API requests, 19 seconds on the platform, no code.**

```json
{
  "specialties": ["Dentist"],
  "states": ["NY"],
  "maxItems": 100
}
```

#### From the API

```python
## pip install apify-client
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("scrapersdelight/nppes-provider-scraper").call(run_input={
    "specialties": ["Nurse Practitioner"],
    "states": ["TX"],
    "maxItems": 5000,
})
for provider in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(provider["npi"], provider["providerName"], provider["phone"])
```

```javascript
// npm install apify-client
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('scrapersdelight/nppes-provider-scraper').call({
    specialties: ['Chiropractor'],
    states: ['FL'],
    maxItems: 2000,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

```bash
## Verify three NPIs and get the rows back in one call
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~nppes-provider-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"npiNumbers":["1679540124","1063386555","1245319599"]}'
```

***

### Read this before you buy rows

**1. The specialty is a PREFIX match, not a substring.** CMS resolves your text against the *start* of a NUCC taxonomy classification or specialization. So `Dentist` works and returns every dental sub-specialty; `Nurse`, `Counselor`, `Internal Medicine` and `Orthodontics` all work; and `Medicine`, `entist` and `dontic` return **nothing** — the API answers *"No taxonomy codes found with entered description"* and this Actor stops with that explanation in the run's status message rather than pushing an empty dataset. The dropdown only contains stems verified live on 2026-08-12, which is also how we found that **`Psychiatrist` is not a taxonomy and fails** — the working stem is `Psychiatry`.

**2. A state on its own is not a valid search.** `state=WY` with no other criteria is rejected by CMS itself: `{"description":"Field state requires additional search criteria","field":"state"}`. You need a specialty, a ZIP, or a list of NPIs. A **ZIP on its own is legal**, which is how you pull a whole-ZIP roster of every provider of every kind (measured: ZIP 10016 returns 38 distinct primary taxonomies in the first 60 rows).

**3. The `state` filter leaks across state lines even with the practice address pinned.** A raw `NY` sweep returns some providers who merely collect post in New York: **688 of 22,189 rows, 3.1%** on the full NY dentist pull (it is 8.4% without the pin). So **"Practice address must be in the selected state" defaults to ON**, which re-checks the parsed row and gives you a clean 21,501-row in-state list. Turn it off to keep every state-linked provider. Either way `practiceState` is on every row.

**4. Slices are a true partition, not overlapping sets — because we pin the address.** By default the CMS API matches city/state/ZIP against **any** address on a record, practice *or* mailing, so neighbouring ZIP cells overlap. Every request here pins `address_purpose=LOCATION`. Measured on `Dentist`, ZIP cells `770*` and `752*` share **5** records without it and **exactly 0** with it. Nothing is lost by pinning: a practice address is mandatory in NPPES, and 0 of 1,200 sampled providers were missing one.

**5. NPPES publishes no email addresses, for anyone.** Not a scraping limitation — the registry does not collect them. Anyone selling you "NPI emails" generated them somewhere else. You get phone (100%), fax (50.3%), a full practice address (100%) and, on organizations, a named authorized official with a direct phone (100%).

***

### What you get

One row per **unique NPI**, deduplicated across every slice of the split.

**Identity**

| Field | Type | Notes |
|---|---|---|
| `npi` | string | The 10-digit National Provider Identifier. The dedupe key. |
| `npiType` | string | `Individual` or `Organization` |
| `enumerationType` | string | `NPI-1` (individual) or `NPI-2` (organization) |
| `status` | string | `Active`. See Honest limits — CMS does not serve deactivated NPIs at all. |

**Name**

| Field | Type | Notes |
|---|---|---|
| `providerName` | string | Person's full name, or the organization name. Never null. |
| `firstName`, `middleName`, `lastName` | string | `null` on organization rows by design |
| `credential` | string | `DDS`, `MD`, `RN`, `DMD`… as the provider self-reported it |
| `gender` | string | `M` / `F`; `null` on organizations |
| `soleProprietor` | string | `YES` / `NO`, individuals only |
| `organizationName` | string | Legal entity name |
| `otherNames` | array | DBA / former / alternate names |

**Decision-maker (organizations)**

| Field | Type | Notes |
|---|---|---|
| `authorizedOfficialName` | string | The named owner or practice manager |
| `authorizedOfficialTitle` | string | `OWNER`, `PRESIDENT`, `OFFICE MANAGER`… |
| `authorizedOfficialPhone` | string | Formatted `646-593-9305` |

**Specialty & licence**

| Field | Type | Notes |
|---|---|---|
| `specialtyQueried` | string | What you asked for. `null` in ZIP-roster and NPI-lookup runs. |
| `matchedTaxonomy` | string | The taxonomy that actually matched — **not always the primary one** |
| `primaryTaxonomy`, `primaryTaxonomyCode` | string | NUCC description + code, e.g. `1223G0001X` |
| `allTaxonomies` | array | Every taxonomy on the record |
| `licenseNumber`, `licenseState` | string | State licence attached to the primary taxonomy |

**Contact & location**

| Field | Type | Notes |
|---|---|---|
| `phone`, `fax` | string | Normalised to `212-688-7722` from the registry's raw 10/11-digit strings |
| `practiceAddress1`, `practiceAddress2`, `practiceCity`, `practiceState`, `practiceZip`, `practiceCountry` | string | The **practice location**, always — never the mailing address |
| `practiceAddress`, `mailingAddress` | string | One-line pre-joined versions for a CRM import |
| `practiceLocationCount` | number | How many SECONDARY practice sites this provider publishes (`0` on 93.8% of rows) |
| `practiceLocations` | array | Every secondary site in full: `address1`, `address2`, `city`, `state`, `zip`, `country`, `phone`, `fax`, `address`. A `states`/`postalCodes` filter matches these too, so this is where a row whose `practiceZip` looks "wrong" actually matched |

**Dates & provenance**

| Field | Type | Notes |
|---|---|---|
| `enumerationDate`, `lastUpdated` | string | ISO dates as published by CMS |
| `profileUrl` | string | The public NPPES page for that NPI |
| `scrapedAt` | string | ISO-8601 UTC timestamp of the run |

ZIPs are rendered `10022-4148` when the registry holds nine digits and `11572` when it holds five. `null` always means *CMS published nothing there* — it never means the scraper skipped it.

**Key-value store artifact:** in NPI-lookup mode the run writes `NPI_NOT_FOUND` — `{ notFound: [...], checked: n, found: n }` — so a credentialing job can diff its own input list programmatically.

***

### Field fill — measured on 21,501 New York dentists

Not a sample: the complete in-state result of a full New York `Dentist` enumeration at default settings, 2026-08-13. The identical measurement on 2026-08-12's 21,499 rows agreed to one decimal place on every line.

| Field | Fill |
|---|---|
| `npi`, `providerName`, `matchedTaxonomy`, `primaryTaxonomy`, `primaryTaxonomyCode` | 100.0% |
| **`phone`** | **100.0%** |
| `practiceAddress1` / `practiceCity` / `practiceState` / `practiceZip` / `practiceCountry` | 100.0% |
| `mailingAddress`, `enumerationDate`, `lastUpdated`, `profileUrl` | 100.0% |
| `licenseState` | 90.9% |
| `licenseNumber` | 90.6% (99.8% of individuals, 59.7% of organizations) |
| `gender` | 77.0% (individuals only, by definition) |
| `credential` | 69.7% (90.5% of individuals) |
| **`fax`** | **50.3%** |
| `middleName` | 43.9% |
| `practiceAddress2` | 24.7% |
| `otherNames` (DBA) | 7.8% |
| `practiceLocations` (secondary practice sites) | 6.2% carry at least one (n=80 random NPIs, 2026-08-19); `practiceLocationCount` is 0 on the rest |

Phone and street address are **100%**. That is unusual — most licence registries we scrape carry contact data on a tenth of rows — and it is why NPPES is the best cold list in US healthcare.

Split by record type on the same 21,501 rows: **77.0% individuals, 23.0% organizations**. Every organization row carries an `authorizedOfficialName`, `authorizedOfficialTitle` and `authorizedOfficialPhone` (**100.0%**) — a named human and a direct line, which is who a DSO, a dental-supply rep or a practice-management SaaS actually needs to reach.

**The headline that could mislead you:** a `licenseNumber` fill of 90.6% is really 99.8% for individuals and 59.7% for organizations. If you are buying this data to reconcile against a state licensing board, filter to `npiType = Individual` first.

**Secondary practice sites.** `practiceAddress*`, `phone` and `fax` are the provider's PRIMARY practice location. NPPES also publishes SECONDARY sites, and the CMS API matches your `states` / `postalCodes` filter against those too — so a ZIP roster can legitimately return a provider whose `practiceZip` is a different ZIP, because the site that matched is one of the secondary ones. Every secondary site is emitted in full in `practiceLocations[]` (address, city, state, ZIP, phone, fax), with `practiceLocationCount` for filtering. Measured 2026-08-19: 6.2% of providers have at least one; NPI 1932975919 has 11 (Miami FL, Princeton NJ, Portland OR, Minneapolis MN, …). `fax` and `practiceAddress2` are read from the practice location only — a MAILING-only fax or address line (~2 pp of rows each, measured on 60 sampled nulls per field) is deliberately not merged in, so the practice-vs-mailing semantics stay clean.

***

### Sample rows

An individual, exactly as the Actor emitted it on 2026-08-13:

```jsonc
{
  "npi": "1679540124",
  "npiType": "Individual",
  "enumerationType": "NPI-1",
  "status": "Active",

  "providerName": "THEODORE MARK AARONSON",
  "firstName": "THEODORE",
  "middleName": "MARK",
  "lastName": "AARONSON",
  "credential": "DDS",
  "gender": "M",
  "soleProprietor": "NO",
  "organizationName": null,

  "authorizedOfficialName": null,
  "authorizedOfficialTitle": null,
  "authorizedOfficialPhone": null,

  "specialtyQueried": "Dentist",
  "matchedTaxonomy": "Dentist, General Practice",
  "primaryTaxonomy": "Dentist, General Practice",
  "primaryTaxonomyCode": "1223G0001X",
  "licenseNumber": "028432-1",
  "licenseState": "NY",
  "allTaxonomies": ["Dentist, General Practice"],

  "phone": "212-688-7722",
  "fax": "212-355-4701",
  "practiceAddress1": "305 E 55TH ST",
  "practiceAddress2": "SUITE 204",
  "practiceCity": "NEW YORK",
  "practiceState": "NY",
  "practiceZip": "10022-4148",
  "practiceCountry": "United States",
  "practiceAddress": "305 E 55TH ST, SUITE 204, NEW YORK, NY, 10022-4148",
  "mailingAddress": "305 E 55TH ST, SUITE 204, NEW YORK, NY, 10022-4148",
  "practiceLocationCount": 0,
  "practiceLocations": [],

  "otherNames": [],
  "enumerationDate": "2006-03-03",
  "lastUpdated": "2026-07-13",
  "profileUrl": "https://npiregistry.cms.hhs.gov/provider-view/1679540124",
  "scrapedAt": "2026-08-13T02:03:25.446Z"
}
```

An organization from the same run — note the personal-name fields are `null` **by design** and the contactable human is the authorized official:

```jsonc
{
  "npi": "1063386555",
  "npiType": "Organization",
  "enumerationType": "NPI-2",
  "status": "Active",

  "providerName": "1 DAVISON AVE DENTISTRY PLLC",
  "firstName": null,
  "lastName": null,
  "credential": null,
  "gender": null,
  "organizationName": "1 DAVISON AVE DENTISTRY PLLC",

  "authorizedOfficialName": "GAVRIEL GULAMOV",
  "authorizedOfficialTitle": "OWNER",
  "authorizedOfficialPhone": "646-593-9305",

  "specialtyQueried": "Dentist",
  "matchedTaxonomy": "Dentist, General Practice",
  "primaryTaxonomy": "Dentist, General Practice",
  "primaryTaxonomyCode": "1223G0001X",
  "licenseNumber": null,
  "licenseState": null,

  "phone": "646-593-9305",
  "fax": null,
  "practiceAddress1": "1 DAVISON AVE W",
  "practiceCity": "OCEANSIDE",
  "practiceState": "NY",
  "practiceZip": "11572-2114",
  "practiceAddress": "1 DAVISON AVE W, OCEANSIDE, NY, 11572-2114",

  "otherNames": ["ELIDENT"],
  "enumerationDate": "2025-09-30",
  "lastUpdated": "2025-10-09",
  "profileUrl": "https://npiregistry.cms.hhs.gov/provider-view/1063386555"
}
```

Three fields people misread:

- `matchedTaxonomy` vs `primaryTaxonomy` — they differ whenever the specialty you searched is a *secondary* taxonomy on that provider. `matchedTaxonomy` is why the row is in your dataset; `primaryTaxonomy` is what the provider calls themselves first.
- `licenseNumber: null` on an organization is normal (59.7% fill). Practices are not licensed the way practitioners are.
- `specialtyQueried: null` is not a bug — it means the run was a ZIP roster or an NPI lookup, where no specialty was asked for.

***

### Recipes

**Recipe 1 — the whole state, cheap first, then everything.** Run it capped, look at the rows, then set `maxItems: 0`.

```json
{ "specialties": ["Dentist"], "states": ["NY"], "maxItems": 0 }
```

**Recipe 2 — practice owners only, with a named decision-maker on every row.** 100% of these carry `authorizedOfficialName` + `authorizedOfficialPhone`.

```json
{ "specialties": ["Dentist"], "states": ["TX", "OK", "NM"], "npiType": "NPI-2", "maxItems": 0 }
```

**Recipe 3 — a metro, not a state.** ZIP prefixes beat state filters when your territory is a city.

```json
{ "specialties": ["Physical Therapist"], "postalCodes": ["100", "101", "112"], "maxItems": 2000 }
```

**Recipe 4 — a whole-ZIP roster with no specialty at all.** Every provider of every kind in that ZIP; the only search shape besides an NPI that CMS accepts without a taxonomy.

```json
{ "postalCodes": ["10016"], "maxItems": 1000 }
```

**Recipe 5 — credentialing / NPI verification.** Every other filter is ignored; unfound NPIs land in `NPI_NOT_FOUND`.

```json
{ "npiNumbers": ["1679540124", "1063386555", "1245319599"] }
```

**Recipe 6 — a national specialty sweep.** Leave `states` empty and it walks all 56 states and territories. Size it first.

```json
{ "specialties": ["Behavior Analyst"], "states": [], "maxItems": 20000 }
```

**Recipe 7 — a taxonomy that is not in the dropdown.** The free-text box is additive and validated on the first request.

```json
{ "specialtiesCustom": ["Nurse Anesthetist, Certified Registered"], "states": ["WY"], "maxItems": 500 }
```

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `specialties` | multi-select | `["Dentist"]` | 56 verified NUCC taxonomy stems. Each is enumerated separately, so two specialties is roughly twice the work and twice the rows. |
| `specialtiesCustom` | string list | — | Escape hatch for any taxonomy not in the dropdown; added to whatever you picked. Use the *opening words* of the taxonomy name. |
| `states` | multi-select | `["NY"]` | 56 states and territories with full names. Empty = the whole country for that specialty. Ignored when ZIPs or NPIs are supplied. |
| `postalCodes` | string list | — | Full ZIPs (`10016`) or prefixes (`100`). Overrides `states`. Legal with no specialty at all. |
| `npiNumbers` | string list | — | Paste 10-digit NPIs to verify a list instead of enumerating. Every other scope filter is ignored. |
| `npiType` | select | Both | `NPI-1` practitioners / `NPI-2` practices. A full NY dentist pull is 77.0% / 23.0%. |
| `practiceStateOnly` | checkbox | `true` | Drops the 3.1% whose practice address is out of state. Ignored in NPI-lookup mode. |
| `activeOnly` | checkbox | `true` | Belt-and-braces: 23,467 of 23,467 rows on an unfiltered pull were already Active. |
| `withPhoneOnly` | checkbox | `false` | Phone fill is 100.0%, so this normally removes nothing. |
| `maxItems` | number | `100` (prefilled `100`) | **Your cost ceiling.** 100 = $0.20, 10,000 = $20. `0` = no cap. Duplicates never count against it. Defaults to 100 so an API/MCP call that omits it can never bill a surprise — raise it for a bulk pull. |
| `maxRequests` | number | `20000` | Compute ceiling, not a billing one. The full NY pull used 278; a dense single ZIP used 689. |
| `concurrency` | number | `4` | 1–8 slices in flight. 4 is the measured balance. |
| `requestDelayMs` | number | `150` | Pause between pages inside one slice. |
| `proxyConfiguration` | proxy | Apify datacenter, on | See the transport ladder below. Residential is not needed. |

#### The 56 verified specialty stems

`Dentist` · `Dental Hygienist` · `Pediatric Dentistry` · `Orthodontics and Dentofacial Orthopedics` · `Endodontics` · `Periodontics` · `Prosthodontics` · `Oral & Maxillofacial Surgery` · `Family Medicine` · `Internal Medicine` · `Pediatrics` · `Obstetrics & Gynecology` · `Emergency Medicine` · `Anesthesiology` · `Dermatology` · `Ophthalmology` · `Optometrist` · `Radiology` · `Surgery` · `Orthopaedic Surgery` · `Urology` · `Cardiovascular Disease` · `Gastroenterology` · `Nephrology` · `Neurology` · `Psychiatry` · `Psychologist` · `Counselor` · `Social Worker` · `Marriage & Family Therapist` · `Behavior Analyst` · `Nurse Practitioner` · `Registered Nurse` · `Physician Assistant` · `Chiropractor` · `Podiatrist` · `Physical Therapist` · `Occupational Therapist` · `Speech-Language Pathologist` · `Audiologist` · `Acupuncturist` · `Massage Therapist` · `Dietitian, Registered` · `Nutritionist` · `Pharmacy` · `Pharmacist` · `Clinic/Center` · `General Acute Care Hospital` · `Skilled Nursing Facility` · `Assisted Living Facility` · `Home Health` · `Hospice Care, Community Based` · `Clinical Medical Laboratory` · `Durable Medical Equipment & Medical Supplies` · `Ambulance` · `Veterinarian`

Each of these was fired at the live API on 2026-08-12 and returned rows. Note the traps the same test exposed: **`Psychiatrist` fails** (use `Psychiatry`), and so do `Medicine`, `entist` and `dontic`.

***

### Pricing

**$0.002 per provider returned — $2.00 per 1,000.** Charged on the `provider-scraped` event, once per **unique NPI**, and there is no monthly platform fee from this Actor.

What you are **not** charged for:

- **Never twice for the same provider.** The split tree deliberately re-reads a parent cell's rows when it splits, and every one of those repeats is dropped on the NPI before anything is pushed. Measured on the full New York pull: 22,189 rows, 22,189 unique NPIs, **0 duplicates**.
- **Never for a row your filters removed.** `practiceStateOnly`, `activeOnly` and `withPhoneOnly` are applied before push. The 688 out-of-state New Yorkers were fetched and discarded free.
- **Never for an NPI that is not in the registry.** Lookup mode bills the ones it finds and lists the rest in `NPI_NOT_FOUND`.
- **Never for our retries or our proxy budget.** A CloudFront 403 retried on a fresh session costs you nothing.

Rows are charged **as they are pushed** (`Actor.pushData(items, 'provider-scraped')`), so if you hit a budget cap you get whole rows and a clean stop, not a half-billed dataset.

| What you pull | Providers | Cost |
|---|---|---|
| The prefilled first click | 100 | **$0.20** |
| Every dentist in Wyoming | 599 | $1.20 |
| Every dentist in New York | 21,501 | $43.00 |
| Every dentist in the United States | 379,654 | ~$759 |

In Apify plan terms: the **$5 free credit is about 2,500 providers**, and the **$29 Starter credit is about 14,500**. `maxItems` is the dial — it is prefilled at 100 on purpose.

***

### Honest limits

- **No email addresses. None, for anybody.** NPPES does not collect them, so no scraper can return them from this source. If you need email you need a separate enrichment step against the practice's own website — this Actor does not do that and does not pretend to.
- **No DEA numbers, no NPPES-published specialties beyond the NUCC taxonomy, no patient or claims data.** The registry does not publish them.
- **A state on its own cannot start a run.** The API rejects it (`Field state requires additional search criteria`). You need a specialty, a ZIP list, or NPIs. This is a CMS rule, not a design choice here.
- **`fax` is 50.3% and `otherNames` is 7.8%.** A blank means CMS published nothing, not that the scrape missed it. `licenseNumber` is 59.7% on organizations.
- **The finest split rung is a two-letter name prefix**, because the API rejects one-letter wildcards (*"Wildcards require at least two leading characters"*). If a bucket is *still* at exactly 1,200 after `aa`–`zz`, some of its providers are unreachable through the public API — and the run **names that bucket in a warning** rather than quietly shipping short. On the full New York pull this never happened: 0 unresolved buckets out of 146.
- **`activeOnly` is nearly a no-op.** CMS does not serve deactivated NPIs through the search API at all: 23,467 of 23,467 rows on an unfiltered pull were Active, 0 deactivated. Do not use this Actor to find *lapsed* providers — it cannot see them.
- **Two specialties cost roughly twice as much as one.** Each is enumerated separately from the root. There is no shared work between them.
- **Cross-run dedupe does not exist.** Deduplication is within a run. Two runs over the same scope return the same providers and bill twice; dedupe on `npi` on your side, or slice by state/ZIP so runs do not overlap.
- **Registry data moves daily.** The same New York search returned 22,187 on 2026-08-12 and 22,189 on 2026-08-13. Treat any total on this page as a measurement with a date on it, not a constant.

***

### How it works, and the transport ladder

There is no browser, no login, no API key and no CAPTCHA solving. Every request is a plain `GET` to the public CMS endpoint:

```
https://npiregistry.cms.hhs.gov/api/?version=2.1&address_purpose=LOCATION
     &taxonomy_description=…&state=…&postal_code=…&enumeration_type=…&limit=200&skip=…
```

`address_purpose=LOCATION` is pinned on every call — that is what turns overlapping ZIP cells into a true partition (see gotcha 4). Pagination walks `skip=0…1000` in 200-row pages, and a bucket that comes back at exactly 1,200 unique NPIs is handed to the splitter.

The API is free and unauthenticated, but it is **not** unlimited — it sits behind **CloudFront with a rate rule**. Measured in this order:

| Rung | Result |
|---|---|
| Direct, un-proxied, home line | 403 HTML `Request blocked` once the IP is rate-flagged — this build's own ZIP-mapping sweep tripped it after ~5,600 calls and it stayed blocked. Reported and then ignored: no Apify container has this IP. |
| **Apify datacenter proxy (the default)** | **200 OK. Sustained load 38/38 consecutive calls (100%) at 1.04 req/s; a full 278-request New York enumeration completed with 0 CloudFront 403s.** |
| Apify RESIDENTIAL | 200 OK, roughly 2× slower, unnecessary |

So the cheap datacenter rung is the default, and a CloudFront HTML 403 is retried on a *fresh proxy session* rather than being mistaken for an empty result. That the site is not walled is both a trust win and a cost win — you are not paying for residential bandwidth you do not need.

***

### Uniqueness and deduplication

The split tree is designed to overlap: when a bucket caps out, its children re-read rows the parent already returned. That is by design, and it is why deduplication happens **before billing**.

- Dedupe key: `npi`, held in a run-wide set.
- Measured on a contiguous full enumeration (146 buckets, 278 requests, every leaf of the New York dentist tree): **22,189 rows pushed, 22,189 distinct NPIs, 0 duplicates.**
- With `address_purpose=LOCATION` pinned, neighbouring ZIP cells `770*` and `752*` share **exactly 0** records. Without it they share 5.
- Across runs there is no dedupe. Slice by state or ZIP so runs do not overlap, or dedupe on `npi` downstream.

***

### When a run stops early

This Actor **explains itself instead of crashing**. Every stop condition below ends the run
**Succeeded**, with the reason in the run's status message and the full detail in the log, and with
everything already collected pushed to the dataset. You are only ever billed for providers that
were actually returned.

- **No specialty, ZIP code or NPI number supplied → the documented sample runs**: `Dentist` + `NY`,
  capped at **100 providers ($0.20)**. A warning names exactly which input to set. This is what an
  API, MCP or agent caller sending `{}` gets — a working example, not an error.
- **Zero rows is reported, not thrown**: the status message carries the request count, the bucket
  count and the two most likely causes (a mid-word specialty, or a genuinely empty scope).
- **An unrecognised specialty is reported on the first request** with the exact CMS message and the
  prefix-matching rule — it never burns your budget walking a tree that cannot match.
- **A state with nothing else is not a search** (the CMS API rejects it outright), so it falls back
  to the sample rather than erroring.
- **An unrecognised state code or provider type is ignored with a warning**, and the rest of the
  scope still runs.
- **A bucket still capped at 1,200 after the finest split is named in a warning**, so an incomplete
  slice is visible instead of silent.
- **CloudFront 403s are retried on a fresh proxy session** and counted in the final log line; they
  are never treated as "no results".
- **In lookup mode, NPIs not in the registry are listed in the log and written to `NPI_NOT_FOUND`**,
  so a credentialing run can diff its input list programmatically.
- **Hitting `maxItems`, `maxRequests` or the run timeout is a warning**, not a failure: the run stops
  taking new work, pushes everything it already has, and the log tells you which limit stopped you.

***

### Who buys this

- **Dental and medical supply / DSO acquisition teams** — `npiType: NPI-2` gives one row per practice with `authorizedOfficialName`, `authorizedOfficialTitle` and a direct `authorizedOfficialPhone` at 100% fill. That is an owner list, not a switchboard list.
- **Practice-management and EHR SaaS (Dentrix, Weave, Tebra, Jane class)** — territory lists by specialty × ZIP prefix with 100% phone and 100% street address, sized before you hire the rep.
- **Medical device and pharma field sales** — `matchedTaxonomy` plus `primaryTaxonomyCode` lets you build sub-specialty target lists (Endodontics, Cardiovascular Disease, Behavior Analyst) rather than blunt "dentists".
- **Credentialing, payer enrollment and primary-source verification vendors** — paste `npiNumbers` and get the registry's own record back, plus an explicit not-found list to work.
- **Healthcare data and analytics teams** — `enumerationDate` and `lastUpdated` on every row make provider-churn and new-entrant analysis possible; `licenseNumber` + `licenseState` is the join key to state board data.
- **Staffing and locum agencies** — `Registered Nurse`, `Nurse Practitioner` and `Physician Assistant` by state, with a phone number on every single row.

***

### How this differs from our other actors

| Actor | What it is | Why you would use it instead |
|---|---|---|
| [npi-registry-scraper](https://apify.com/scrapersdelight/npi-registry-scraper) | The same registry, **one query, no split** — stops at the 1,200 ceiling by design | Cheaper at **$0.0005 per provider**. Use it for a named provider, one city, one NPI, or any search you already know is under 1,200. No amount of paging it will get you a whole specialty. |
| **nppes-provider-scraper** (this one) | The **bulk enumerator**. Splits the query tree until an entire specialty comes out | You need all 22,189 New York dentists, not the first 1,200. |
| [healthgrades-scraper](https://apify.com/scrapersdelight/healthgrades-scraper) | Healthgrades provider profiles | You want **star ratings, review counts, awards, "accepting new patients" and per-office GPS** — consumer-facing signals NPPES does not carry. $0.0015/provider. |
| `florida-medical-license-scraper` | Florida DOH MQA primary source | You need **licence status, expiry dates and full disciplinary history** for Florida. NPPES carries a licence *number*, never a licence *status*. |

The honest one-liner: **NPPES is where you go for complete coverage and a phone number on every row; a state board is where you go to find out whether that licence is actually in good standing.**

***

### Integrations

The dataset is a normal Apify dataset, so everything downstream already works: **CSV / Excel / JSON / XML export**, the **Dataset API**, **webhooks** on run completion, and the official **Zapier, Make, n8n, Slack and Google Sheets** integrations. A typical pipeline is Schedule → this Actor → webhook → your CRM, with `npi` as the upsert key and `lastUpdated` as the change detector.

***

### FAQ

**Does this need an account, a login or an API key for NPPES?**
No. The CMS NPPES API is public and unauthenticated. This Actor logs into nothing and solves no challenges.

**Why did other NPI scrapers give me only 1,200 rows?**
Because that is the API's hard cap per query, and most tools page until the API stops giving them new rows and call it done. This one detects the cap and splits the query — 1,200 → 22,189 on a single New York dentist search.

**Can I get the entire registry in one run?**
Not sensibly. Enumerate one specialty at a time — that is the dimension the splitter is built around. All 379,654 US dentists is a real, costed run (~$759); "all 8 million NPIs" is not what this Actor is for.

**How much does it cost?**
$2.00 per 1,000 providers, billed per unique NPI delivered, no per-run fee. The $5 free Apify credit is about 2,500 providers.

**Do I get charged for rows that get filtered out, or for duplicates?**
No to both. Filters run before push, and duplicates are dropped on the NPI before push. Measured 0 duplicates across a 22,189-row enumeration.

**Two runs over the same state — will I get duplicates?**
Yes. Dedupe is per run. Dedupe on `npi` downstream, or slice by state / ZIP so runs do not overlap.

**Can I look up specific NPI numbers?**
Yes — paste them into `npiNumbers` and every other filter is ignored. Ones that do not exist are reported in the log and in the `NPI_NOT_FOUND` key-value record.

**Can I get email addresses?**
No. NPPES does not collect them. You get phone at 100%, fax at 50.3%, a full practice address at 100%, and a named authorized official with a direct phone on 100% of organization rows.

**Does it need a proxy?**
Yes, and it is on by default. The API sits behind CloudFront with a rate rule; a single unproxied IP got blocked after roughly 5,600 calls during development. The cheap Apify datacenter pool is enough — residential is about 2× slower and buys nothing.

**Why does my specialty return nothing?**
CMS prefix-matches taxonomy names. `Medicine`, `entist` and `dontic` match nothing; `Internal Medicine`, `Dentist` and `Orthodontics` do. `Psychiatrist` also fails — the taxonomy is `Psychiatry`. Pick from the dropdown and this cannot happen.

**Why is `providerName` filled but `firstName` empty on some rows?**
Those are organizations. NPPES stores no personal name for a business — the human is in `authorizedOfficialName`, which is filled on 100% of organization rows.

**Can I pull a whole city rather than a state?**
Use `postalCodes` with prefixes — `["100","101","112"]` is most of New York City. It is also the only way to pull rows with no specialty filter at all.

**Will a run ever succeed with zero rows?**
Only when the registry genuinely has nothing for your scope. The run ends Succeeded with a status
message naming the scope, the request count and the two usual causes (a mid-word specialty, or a
filter combination that matches nothing), and **nothing is pushed and nothing is billed**. It does
not crash the run — a failed run is useless to an agent or a scheduled task.

**How fresh is the data?**
It is read live from CMS on every run, and CMS updates NPPES daily. Every row carries `lastUpdated` so you can see the registry's own freshness per provider.

**Can I schedule it?**
Yes — use Apify Schedules. `lastUpdated` and `enumerationDate` make weekly new-provider detection straightforward on your side.

***

### Legal & fair use

This Actor reads the **public NPPES NPI Registry API** published by the Centers for Medicare & Medicaid Services. NPI data is a US public record, released by CMS under its own data-dissemination notice; there is no login, no paywall and no authentication involved, and this Actor collects nothing that is behind any authentication.

NPI records contain **personal data about named individuals** — practitioner names, practice addresses and phone numbers, and the names and direct phone numbers of authorized officials. You are responsible for complying with the NPPES data-dissemination notice and with how you use the data, including TCPA, CAN-SPAM, state telemarketing and do-not-call rules, and GDPR/CCPA where they apply to you. Nothing here is medical, legal or compliance advice.

NPPES and the NPI Registry are operated by CMS. This Actor is an independent tool and is not affiliated with, endorsed by, or sponsored by the Centers for Medicare & Medicaid Services or the US Department of Health and Human Services.

***

### Changelog

| Build | Date | Change |
|---|---|---|
| 0.1.4+ | 2026-08-13 | NPI-lookup mode (`npiNumbers` + `NPI_NOT_FOUND`), specialty-free ZIP rosters, 56-stem specialty dropdown + free-text escape hatch, 56-state multi-select, `maxItems` prefill dropped 2,000 → 100. All coverage and fill numbers re-measured against the live API. |
| 0.1.1 – 0.1.3 | 2026-08-12 | First release: cap-aware split enumeration, `address_purpose=LOCATION` partition pinning, datacenter-proxy transport. |

**Last verified against the live NPPES API: 2026-08-13.**

***

### Support

Found a missing field, a taxonomy stem that should be in the dropdown, or a filter you need? Open an issue on the **Issues** tab of this Actor and it goes straight to the developer.

If this saved you a week of paging a capped API, a ⭐⭐⭐⭐⭐ rating genuinely helps other people find it.

***

**Keywords:** NPI registry scraper · NPPES scraper · NPI number lookup · NPI database export · CMS provider registry · healthcare provider list · doctor list by specialty · dentist email list alternative · dentist phone list · medical practice owner list · DSO acquisition targets · NUCC taxonomy · provider taxonomy code · NPI-1 NPI-2 · authorized official contact · practice manager phone list · nurse practitioner list by state · registered nurse database · chiropractor leads · physical therapist leads · optometrist list · pharmacy directory · home health agency list · skilled nursing facility list · hospice provider list · DME supplier list · clinical laboratory list · credentialing data · primary source verification · payer enrollment · provider directory · healthcare lead generation · medical device sales leads · pharma territory list · provider data enrichment · NPI bulk download · past the 1200 record limit

# Actor input Schema

## `specialties` (type: `array`):

Pick one or more specialties — each is enumerated separately, and every one in this list was verified live against the CMS API on 2026-08-12. CMS matches your text against the START of a NUCC taxonomy classification or specialization, which is why "Dentist" returns every dental sub-specialty (Dentist, Periodontics · Dentist, Orthodontics and Dentofacial Orthopedics · …) and why a mid-word fragment such as "Medicine", "entist" or "dontic" matches nothing at all. Leave empty ONLY if you are pasting ZIP codes or NPI numbers below — a state on its own is rejected by the API.

## `specialtiesCustom` (type: `array`):

Escape hatch for any NUCC taxonomy not in the dropdown — it is added to whatever you picked above. Type the OPENING WORDS of the taxonomy name, e.g. "Legal Medicine", "Nurse Anesthetist, Certified Registered", "Transportation Services". If CMS does not recognise it the run stops immediately with that explanation rather than pushing an empty dataset.

## `states` (type: `array`):

Which states or territories to sweep. Leave EMPTY to sweep all 56 — that is the full national pull for the chosen specialty and it can run to hundreds of thousands of providers, so set "Max providers" first. Ignored when you paste ZIP codes or NPI numbers below.

## `postalCodes` (type: `array`):

Restrict to specific ZIPs instead of whole states — full 5-digit ZIPs (10016) or shorter prefixes (100 = every ZIP starting 100). When this is set, States is ignored. A ZIP that still exceeds 1,200 providers is split further automatically. A ZIP on its own is a legal search even with NO specialty, so this is also how you pull a whole-ZIP roster of every provider of every kind.

## `npiNumbers` (type: `array`):

Paste 10-digit NPI numbers to verify an existing list instead of enumerating a specialty. One API call per NPI, and EVERY other scope filter is ignored — you asked for these exact records. NPIs the registry does not hold are listed in the log and written to the key-value store under NPI\_NOT\_FOUND, so a credentialing run can diff its input list. You are billed only for the NPIs that came back with a record.

## `npiType` (type: `string`):

Individuals (NPI-1) are the practitioners; organizations (NPI-2) are the practices, clinics and groups — and 100.0% of organization rows carry an authorized-official name, title and phone, which is the decision-maker for most B2B outreach. A full New York dentist pull is 77.0% individuals / 23.0% organizations. Both = everything.

## `practiceStateOnly` (type: `boolean`):

IMPORTANT. The CMS state filter matches ANY address on a record — practice OR mailing — so a raw "NY" sweep also returns providers who merely receive post in New York. Every request already pins the practice address, which removes ZIP-cell overlap entirely, but state still leaks: measured on a full NY dentist pull, 688 of 22,187 rows (3.1%) practised out of state. Leave ON for a clean in-state list; turn OFF to keep every state-linked provider. Ignored in NPI-lookup mode.

## `activeOnly` (type: `boolean`):

Drop deactivated NPI records. In practice CMS does not publish deactivated NPIs through the search API at all — measured on an unfiltered 23,467-row pull, 23,467 were active and 0 deactivated — so this is a belt-and-braces filter rather than a real one.

## `withPhoneOnly` (type: `boolean`):

Keep only rows carrying a practice phone. Measured on 23,467 live NY dentist rows, phone fill is 100.0% — unlike the state licence registries, NPPES contact data is complete, so this filter normally removes nothing. Kept for the rare specialty where it does.

## `maxItems` (type: `integer`):

Stop after this many unique providers. THIS IS YOUR COST CEILING: you are billed $0.002 per provider returned, so 100 = $0.20 and 10,000 = $20. Duplicates are dropped before billing and never count against it. Defaults to 100 so an API/MCP call that omits it can never bill a surprise — raise it for a real bulk pull. Set 0 for no cap — only with a scope you have already sized, since a nationwide specialty can run into the hundreds of thousands.

## `maxRequests` (type: `integer`):

Hard ceiling on calls to the CMS API — compute, not billing. Roughly one request per 200 providers, plus one probe per empty slice: the complete 22,187-row New York dentist pull took 278 requests, and a dense single ZIP (10016 + Student, 2,653 providers) took 689.

## `concurrency` (type: `integer`):

How many slices to fetch at once (1–8). 4 is the measured balance and the default; the CMS API is behind CloudFront and a high sustained rate from one egress IP gets rate-blocked, which is why the proxy below is on by default. Raise it only if you also leave the proxy on.

## `requestDelayMs` (type: `integer`):

Pause between paginated requests inside one slice. 150 ms held a sustained-load test of 38/38 consecutive calls (100%) at 1.04 req/s through the Apify datacenter proxy.

## `proxyConfiguration` (type: `object`):

Apify datacenter proxy, ON by default and recommended. The NPPES API needs no login, no API key and no residential IPs, but it sits behind CloudFront with a rate rule: during this Actor's own ZIP-mapping sweep a single un-proxied IP was blocked after roughly 5,600 calls and kept returning HTML 403 pages. The rotating datacenter pool held 38/38 consecutive calls with no block; RESIDENTIAL also works, is about 2× slower, and is not needed.

## Actor input object example

```json
{
  "specialties": [
    "Dentist"
  ],
  "states": [
    "NY"
  ],
  "npiType": "",
  "practiceStateOnly": true,
  "activeOnly": true,
  "withPhoneOnly": false,
  "maxItems": 100,
  "maxRequests": 20000,
  "concurrency": 4,
  "requestDelayMs": 150,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per unique NPI: name, credential, specialty, license, phone, fax and practice address.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "specialties": [
        "Dentist"
    ],
    "states": [
        "NY"
    ],
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/nppes-provider-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "specialties": ["Dentist"],
    "states": ["NY"],
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/nppes-provider-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "specialties": [
    "Dentist"
  ],
  "states": [
    "NY"
  ],
  "maxItems": 100
}' |
apify call scrapersdelight/nppes-provider-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/nppes-provider-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/ri9eXsEGtsK96vdXL/builds/DBfdNbhJCICiUoGVd/openapi.json
