NPPES NPI API returns errors with HTTP 200, and skip repeats past 1,000
A successful status, an empty list, and no providers who are actually missing

Start with the request that looks the most harmless. On 2026-09-13 (Japan time) we asked the official NPPES NPI Registry API for healthcare providers in California — one state filter, nothing else:
GET https://npiregistry.cms.hhs.gov/api/?version=2.1&state=CA
status: 200
content-type: application/json
body:
{"Errors":[{"description":"Field state requires additional search criteria","field":"state","number":"07"}]}
The status line says 200. The body says the search was refused. And the count field, result_count, is not in the response at all — not zero, absent. A client that checks the status code and then counts results gets an empty list from a successful request, and the natural conclusion is "no providers matched in California". That conclusion is false, and nothing in the transport layer tells you so.
Every figure in this article comes from ten requests we sent to npiregistry.cms.hhs.gov/api/?version=2.1 on 2026-09-13 Japan time (the evening of 2026-09-12 in the US), one at a time with 6.5 seconds between them. Where a figure comes from an earlier measurement on 2026-09-10, the date is written next to it. We kept the probe script and its raw responses on our side.
A 200 from this API is not a verdict on your search
Most HTTP APIs use the status code to separate "your request was malformed" from "your request worked". NPPES does not. All three refusals we sent came back as HTTP 200 with a JSON object whose only key is Errors; none of them was a 400 or a 422.
This matters more than it would for most APIs, because the two outcomes that matter most in a provider search look identical if you only read the status and the result array:
- The registry refused the search, so you learned nothing about who practises where.
- The registry ran the search and nobody matched, which is real information.
A lead list, a credentialing check or a network-adequacy report built on the first case silently reports a gap in coverage that does not exist. And the refusal is triggered by perfectly reasonable-looking input: a state on its own.
Three refusals, three error numbers
We sent three inputs that the registry refuses. All three returned HTTP 200, none contained result_count, and each carried a different number in the error object:
What we sent (after version=2.1) | Status | number | field | description |
|---|---|---|---|---|
| nothing | 200 | 04 | generic | No valid search criteria provided |
state=CA | 200 | 07 | state | Field state requires additional search criteria |
taxonomy_description=cardiologist&state=NY | 200 | 14 | taxonomy_description | No taxonomy codes found with entered description |
The second row is the one that catches people. A state is accepted as a filter but not as a search: the error asks for "additional search criteria" alongside it, and in our requests a state combined with a specialty or with a surname was accepted. The rule is sensible — an unrestricted state query would be enormous — but it is enforced with a 200.
The third row is a different failure wearing the same clothes, and we come back to it below. For now, note that number and field are machine-readable (error 07 came back with the same number, field and wording on 2026-09-10 and on 2026-09-13). If you log anything from a refused request, log those two, because they tell you whether to fix the combination of fields (07), the vocabulary (14), or an empty request (04).
What a genuine zero looks like
A search that the registry accepts and that matches nobody is shaped differently. We searched for a surname that does not exist in California:
GET https://npiregistry.cms.hhs.gov/api/?version=2.1&last_name=Zzqqxxvw&state=CA
status: 200
body:
{"result_count":0,"results":[]}
Here result_count is present and equals 0, and there is no Errors key. So the three outcomes can be told apart, but only by reading the body:
Errorspresent → refused. Nothing was searched.result_countpresent and 0 → searched, nobody matched.result_countpresent and above 0 → searched, rows returned.
The trap is writing data.result_count || 0 or data.results?.length ?? 0, because both collapse the first two lines into one. The check has to be for the presence of Errors first, before anything reads a count.
There is a second thing result_count does not tell you, which becomes important in the paging section: it is the number of records in this response, not the number that match. Every one of the dentist queries below returned result_count: 200, whether we were on the first page or asking for record 20,000.
The registry's vocabulary, not the one people type
Error 14 deserves its own section, because it turns an ordinary word into a refusal. taxonomy_description is matched against the registry's own taxonomy names, which are not the words a person searching for a doctor would use. Measured in New York:
taxonomy_description with state=NY | Result | Measured |
|---|---|---|
cardiologist | Refused, error 14: "No taxonomy codes found with entered description" | 2026-09-13 |
cardiology | result_count: 7 | 2026-09-13 |
Cardiovascular Disease | 200 providers (the page limit) | 2026-09-10 |
Read that table from the bottom. The specialty most people mean when they type "cardiologist" is filed under Cardiovascular Disease, which fills a full page in New York. "Cardiology" is accepted and returns seven. "Cardiologist" is not a taxonomy name at all, so it is refused. Only the refusal announces itself as a problem. Seven providers for the whole state of New York looks like a thin result, not a broken one.
Organisation names behave in a similar way. On 2026-09-10, an organisation-name search for Kaiser returned 15 records while Kaiser Permanente returned 200 (the page limit). We did not re-measure those two today, so treat them as a dated example rather than a current count. The practical lesson is the same either way: a small, non-empty result from this API is not evidence that the market is small. It is often evidence that the word did not match the registry's spelling.
If you build a search box on top of the registry, the useful behaviour is to surface error 14 to the person typing, in the registry's own words, instead of rendering an empty table. That one message is the difference between "there are no cardiologists" and "try a different word".
limit stops at 200 and says nothing
The limit parameter has a ceiling of 200. Asking for more is not refused; it is quietly reduced. On 2026-09-13:
GET https://npiregistry.cms.hhs.gov/api/?version=2.1&taxonomy_description=dentist&state=CA&limit=1200
status: 200
body:
{"result_count":200,"results":[ ...200 records... ]}
No error object, no warning field. If your code asks for 1,200 and loops until it has received 1,200, it will send another request; if it asks for 1,200 and assumes one response is the whole answer, it keeps a sixth of what it asked for. Either way the cap has to be known in advance, because the response will not mention it.
That leads everyone to the same next step: page with skip. And that is where silent answers start costing you rows.
skip keeps answering after it stops working
We requested the same search — dentists in California, 200 at a time — at four offsets, and compared the NPIs that came back:
skip | Status | result_count | First NPI | Last NPI |
|---|---|---|---|---|
| 800 | 200 | 200 | 1932772191 | 1295652956 |
| 1,000 | 200 | 200 | 1235460916 | 1710697255 |
| 1,200 | 200 | 200 | 1235460916 | 1710697255 |
| 20,000 | 200 | 200 | 1235460916 | 1710697255 |
Comparing the full sets of 200 NPIs rather than just the first and last: the pages at skip=800 and skip=1000 share 0 records, which is what paging should look like. The pages at skip=1000 and skip=1200 share 200 of 200. So do skip=1000 and skip=20000. So the page at skip=1000 is still new, and both offsets we tried beyond it — 1,200 and 20,000 — returned that same page again, with the same status and the same result_count.
These are not new numbers. We measured the same query on 2026-09-10 and got the same first and last NPI at the same offsets, on two separate days. That stability is worth stating, because it means this is how the endpoint behaves, not a caching accident on one afternoon.
Now combine it with the previous section. result_count is 200 on every full page whether the page is new or not, and no error appears when the offset stops being honoured. A loop written the obvious way — "request the next page while the last page was full" — never sees a short page. Each request keeps returning 200 rows, so the loop ends only when something else stops it. The last new page is the one at skip=1000, so this search reaches at most 1,200 records (offsets 0 to 1,000); later pages repeat the skip=1000 page. A loop with a target of 2,000 therefore stops on time and hands back a table that looks complete: 2,000 rows, of which the last 800 are four copies of the same 200. That is arithmetic from the offsets above, and it agrees with the 1,200 unique providers we recorded for the same search on 2026-09-10.
The fix is not to trust the offset. Keep the NPIs you have already delivered, drop repeats before they reach the output, and treat a page that brings no new NPI as the end of what this search can reach. Then say so — to the log, and to whoever reads the table — together with the obvious remedy, which is to narrow the search: split by city or postal code, or by a more specific taxonomy, so that no single query needs more than the 1,200 records it can reach.
Read the body before you believe the status
None of this requires a clever workaround. It requires reading the body before trusting the status, which is a habit rather than a technique:
- Look for
Errorsbefore reading any count. Lognumberandfield. A refused search and an empty search are different results and need different fixes. - Treat an absent
result_countas a refusal, not as zero. Zero is written out as"result_count":0when it is real. - Know the caps before you send.
limitabove 200 is cut to 200 without comment, and in our requests every offset past 1,000 returned theskip=1000page again. - De-duplicate by NPI and stop on a page with nothing new. The status code, the page size and
result_countwill all tell you everything is fine.
These checks are built into the NPI tool we publish for this registry — NPI Registry Scraper & API returns a refused search as a row that says it was refused, in the registry's own wording; checks field combinations such as a state on its own before the request goes out; never charges for refused or empty searches; drops repeated NPIs; and reports how far a search could actually reach when paging stops advancing.
Published by GRAMSHIFT. All counts above were read from live responses of npiregistry.cms.hhs.gov/api/?version=2.1: ten requests on 2026-09-13, plus figures explicitly dated 2026-09-10 (Cardiovascular Disease in New York, the two Kaiser searches, the matching first and last NPIs at the same offsets, and 1,200 unique providers for the dentist search). NPPES data is published by the Centers for Medicare & Medicaid Services; this article is not affiliated with CMS. On the use of AI: the probe script and this article were written by Claude, an AI model, working for GRAMSHIFT; before publishing, the 2026-09-13 figures were compared against the saved raw responses and the 2026-09-10 figures against our earlier written measurement record.