SimpleNPI API
The SimpleNPI API files Type 1 NPI applications with CMS on an applicant's behalf, as a registered surrogate, and reports the result. A partner creates an application. SimpleNPI collects what CMS requires directly from the applicant on a hosted page, files it, and returns the issued NPI by webhook or on request.
Request flow
Your reference, optional prefill, return URL.
An exact match on the public registry ends the application as npi_exists. Nothing is filed twice.
Enters DOB, SSN, birth state and any missing fields. E-signs the surrogate authorization.
Status moves to filing, then issued or pended.
Receives the application id and status. Fetches the NPI with GET /applications/{id}.
Environments
| Environment | Base URL | Keys |
|---|---|---|
| Production | https://api.simplenpi.com/v1 | live_… |
| Sandbox | https://sandbox.api.simplenpi.com/v1 | test_… |
All requests and responses are JSON over HTTPS (TLS 1.2 or later). Timestamps are RFC 3339 in UTC. Identifiers are prefixed strings: app_, evt_, whk_.
Authentication
Send your API key as a bearer token on every request. Keys are issued per partner per environment and carry scopes. A request to an endpoint outside the key's scopes returns 403 forbidden.
curl https://api.simplenpi.com/v1/applications/app_8f2k… \
-H "Authorization: Bearer live_sk_…"
| Scope | Grants |
|---|---|
lookup | POST /lookup, GET /verify/{npi}, GET /registry/{npi} at the keyed rate limit |
applications:write | Create, cancel, resend link, purge |
applications:read | Retrieve and list applications |
webhooks | Manage webhook endpoints and replay events |
- Key rotation: two keys can be live at once. Create the new key, deploy it, then revoke the old one from the dashboard.
- Lookup without a key:
POST /lookupandGET /verify/{npi}also accept unauthenticated calls at the per-IP limit. Everything else requires a key. - Keys are secrets. Keep them server-side. The hosted link uses a separate signing secret, so no API key is ever placed in a browser.
Errors
Errors use conventional HTTP status codes and a single JSON shape. code is stable and safe to branch on. message is for logs and may change. field is present on validation errors. Every response carries X-Request-Id; include it when contacting support.
{
"error": {
"code": "invalid_request",
"message": "mobile must be E.164",
"field": "applicant.mobile"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body or a field failed validation. field names it. |
| 401 | unauthorized | Missing, revoked or wrong-environment key. |
| 403 | forbidden | Key lacks the scope for this endpoint. |
| 404 | not_found | No object with that id for this partner. |
| 409 | duplicate | partner_ref already exists. The body includes the existing id. |
| 409 | invalid_state | Action not allowed in the current status, for example cancelling an issued application. |
| 422 | idempotency_mismatch | Same Idempotency-Key reused with a different request body. |
| 429 | rate_limited | Limit exceeded. Honor Retry-After. |
| 500 | internal_error | Unexpected failure. Safe to retry with the same idempotency key. |
| 503 | registry_unavailable | The CMS registry is down. Lookups fail; application creation still succeeds and the lookup runs when the registry returns. |
Versioning
The major version is in the path (/v1). Within a major version, changes are additive only. Your integration should tolerate these without a code change:
- New fields on any object, new optional request parameters, new event types.
- New values for
status,pend_reasonanderror.code. Treat unknown values as "in progress" or "unknown error", not as failures. - Changes to
messagetext and to field order.
Breaking changes ship as a new major version. The previous version stays available for at least 12 months after the new one is announced, with the end date published in the changelog at GET /v1/changelog and sent to the partner's technical contact.
Idempotency
Every POST accepts an Idempotency-Key header (any string up to 255 characters, a UUID is typical). A retry with the same key within 24 hours returns the original response, including the original status code, and performs no second action. The same key with a different body returns 422 idempotency_mismatch.
partner_ref is a second, permanent guard on application creation: it is unique per partner, and a later POST /applications with the same value returns 409 duplicate with the existing id. Use your own applicant or onboarding id as partner_ref so a retried or re-run onboarding step can never open two applications for one person.
POST /v1/applications Authorization: Bearer live_sk_… Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7 Content-Type: application/json
Rate limits
| Endpoint group | Limit | Window |
|---|---|---|
| Lookup and verify, no key | 30 requests per IP | 1 minute |
| Lookup and verify, with key | 300 requests per key | 1 minute |
| Applications, create | 600 per key | 24 hours, rolling |
| All other authenticated calls | 600 per key | 1 minute |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). A 429 includes Retry-After in seconds. Back off exponentially with jitter. Limits can be raised per partner on request. Polling GET /applications/{id} is allowed but webhooks are the intended path to status changes.
Data handling
This section is the contract most partners review first. It states, per data element, who supplies it, whether it ever leaves your systems, what comes back and how long SimpleNPI keeps it. The same terms appear in the data processing addendum.
Principles
- Nothing about the applicant is required from you.
POST /applicationsneeds onlypartner_ref. Any applicant field you include is optional prefill; omit a field and the applicant enters it on the hosted page. - Prefill is field-level opt-in. Send a name and nothing else, or a name and an email, or nothing. There is no "send the whole profile" mode.
- SSN, date of birth and birth state are never accepted by the API, never returned by it, and never included in a webhook. They exist only between the applicant's browser and SimpleNPI.
- Webhooks are thin by default. A webhook carries the application id, your reference, the new status and a timestamp. Nothing about the person. You fetch details over your authenticated connection when you want them.
- Returned data is registry data. The NPI, name as enumerated, taxonomy and practice address come back exactly as CMS publishes them in the public NPI Registry. SimpleNPI returns no other applicant data to the partner.
- Single purpose. Applicant data is used to file the application, support it with the CMS Enumerator, and report status to you. It is not used for marketing to the applicant, not shared with any party other than CMS, and not sold.
- Purge on request.
DELETE /applications/{id}/applicant-dataremoves everything except the records CMS requires a surrogate to keep.
Data map
| Data element | From partner | From applicant | Returned to partner | Retained by SimpleNPI |
|---|---|---|---|---|
| Partner reference, return URL | Required | — | Yes | Life of the record |
| Legal name | Optional prefill | Yes, if not prefilled | As enumerated in the registry | Life of the record |
| Email, mobile | Optional prefill | Yes, if not prefilled | No | 30 days after terminal status, then deleted |
| Profession, state, license number | Optional prefill | Yes, if not prefilled | Taxonomy and license as in the registry | Life of the record |
| Mailing and practice address | Optional prefill | Yes, if not prefilled | Practice address as in the registry | Life of the record |
| Date of birth, birth state | Not accepted | Yes | Never | Deleted at terminal status |
| SSN | Not accepted | Yes | Never | Encrypted at rest (KMS); deleted at terminal status |
| Signed surrogate authorization | — | E-signed on hosted page | PDF by URL on request | 7 years, as CMS requires of surrogates |
| NPI, registry record | — | — | Yes | Life of the record (public data) |
| Audit log (who did what, when) | — | — | On request | 7 years, contains no SSN or DOB |
| Lookup queries | Name, state, license | — | Match result | Not stored. Only a rate-limit counter per key or IP. |
"Terminal status" means issued, npi_exists, failed, cancelled or expired. "Life of the record" means until you purge it or the partner agreement ends, whichever comes first.
Security
- Transport: TLS 1.2 or later everywhere. HSTS on all hosts. Webhooks are delivered to HTTPS endpoints only.
- Storage: AWS, single US region. Datastore encrypted at rest; SSN in a separately keyed KMS-encrypted column with access logged.
- Filing worker: runs in an isolated environment that holds the NPPES surrogate session. It has no inbound network access and reads one application at a time.
- Hosted page: the applicant link is a signed, single-use token that expires after 14 days. The page requires a one-time code sent to the applicant's mobile before SSN entry.
- Classification: applicant PII, no PHI. Covered by a data processing addendum; a BAA is not required. Subprocessors (cloud, SMS, email) are listed in the DPA.
- Disclosure: every applicant-facing page and every human-readable response states that the NPI is free from CMS at nppes.cms.hhs.gov and that SimpleNPI is a private filing service.
Lookup
Lookup and verify wrap the public CMS NPI Registry (refreshed by CMS daily) with SimpleNPI's matching rules. They accept only data the registry already publishes. Queries are not stored. Both endpoints allow cross-origin requests from any browser origin, so they can be called from a web form directly.
/lookupNo key requiredPOST /applications. Call it directly to check before you start an onboarding step.{
"first": "Jordan",
"last": "Rivera",
"state": "UT",
"license_number": "1234567-4701" // optional
}{
"matches": [{
"npi": "1234567893",
"type": "individual",
"name": "Jordan Rivera",
"state": "UT",
"taxonomy": "225700000X",
"status": "active"
}],
"confidence": "exact",
"registry": { "source": "nppes", "refreshed": "daily" },
"disclosure": "The NPI is free from CMS at nppes.cms.hhs.gov. SimpleNPI is a private filing service."
}| Field | Type | Notes |
|---|---|---|
first, lastrequired | string | Legal name. Hyphens, apostrophes and suffixes are normalized. Omit both when searching by organization. |
organizationoptional | string | Business search in place of first/last. 2 or more characters; a trailing * makes a prefix match. Matches legal and doing-business-as names. |
staterequired | string | Two-letter license or practice state. |
license_numberoptional | string | Raises a match to exact when it matches the registry's license field. Individuals only. |
matches[].type | enum | individual or organization. Organization matches carry the legal name and dba when one is on file. |
confidence | enum | exact (same name, state and discipline, or a license match), possible, none. |
registry | object | On every lookup and verify response: source and refreshed: "daily", so callers know how current the data is. |
/verify/{npi}No key required{
"npi": "1234567893",
"status": "active",
"enumeration_type": "NPI-1",
"name": "Jordan Rivera",
"taxonomy": [{ "code": "225700000X", "primary": true,
"state": "UT", "license": "1234567-4701" }],
"practice_address": { … },
"enumeration_date": "2026-10-27",
"checks": { "taxonomy_matches_profession": true }
}A deactivated NPI returns status: "deactivated" with deactivation_date. An NPI not in the registry returns 404 not_found. GET /registry/{npi} returns the unmodified CMS record for partners that store it.
Applications
An application is one NPI filing for one person. It is created by you, completed by the applicant, filed by SimpleNPI, and ends in a terminal status.
The application object
| Field | Type | Description |
|---|---|---|
id | string | Unique id, prefix app_. |
partner_ref | string | Your id for this applicant. Unique per partner. |
status | enum | See lifecycle below. |
npi | string, nullable | Ten digits. Set when status is issued or npi_exists. |
issued_at | timestamp, nullable | When CMS enumerated the NPI. |
registry_record | object, nullable | Same shape as GET /verify/{npi}. Public registry data only. |
authorization_pdf_url | string, nullable | Signed URL to the e-signed surrogate authorization. Expires in 1 hour; request the object again for a fresh one. |
applicant_link | string, nullable | Hosted page URL while awaiting_applicant. Single use, signed. |
link_expires_at | timestamp, nullable | 14 days after creation or last resend. |
pend_reason | enum, nullable | Set while pended: name_mismatch, ssn_verification, address_review, enumerator_request, other. |
eta | timestamp, nullable | Estimated resolution while pended. |
events | array | Status history: [{at, type}]. |
created_at, updated_at | timestamp |
The object never contains the applicant's email, mobile, date of birth, SSN or anything else entered on the hosted page.
Lifecycle
awaiting_applicant→ready→filing→issuedfiling→pended→issued / failednpi_existscancelledexpired- awaiting_applicant: link created. Ends as
expiredif unused for 14 days. - ready: the applicant finished the hosted page and signed. Filing starts within one minute.
- filing to issued: usually within 10 minutes. Submissions made overnight issue by about 6 am Eastern.
- pended: CMS asked for review. SimpleNPI works it with the Enumerator;
etais updated as it changes. Can take days. - failed: CMS rejected the application and it cannot be corrected. The reason is in the last event.
/applicationsapplications:write201 with an applicant_link, or 201 with status: "npi_exists" and npi set when the lookup finds an exact match. Only partner_ref is required.{
"partner_ref": "hw-48213",
"return_url": "https://partner.com/onboarding/npi"
}
{
"partner_ref": "hw-48213",
"return_url": "https://partner.com/onboarding/npi",
"applicant": {
"first": "Jordan", "last": "Rivera",
"email": "jordan@example.com",
"profession": "massage_therapist",
"state": "UT"
},
"options": { "notify": false }
}{
"id": "app_8f2k…",
"partner_ref": "hw-48213",
"status": "awaiting_applicant",
"applicant_link": "https://simplenpi.com/a/<signed>",
"link_expires_at": "2026-11-10T17:00:00Z",
"npi": null,
"lookup": { "confidence": "none" },
"created_at": "2026-10-27T17:00:00Z"
}| Parameter | Type | Notes |
|---|---|---|
partner_refrequired | string | Your applicant or onboarding id. Unique per partner. Duplicate returns 409 with the existing id. |
return_urloptional | string | Where the hosted page sends the applicant when done. Must be HTTPS and on a domain registered to your partner account. |
applicant.first, applicant.lastoptional | string | Prefills name. Both or neither. |
applicant.emailoptional | string | Prefills contact. Required only if options.notify is true and mobile is absent. |
applicant.mobileoptional | string, E.164 | Prefills contact. Required only if options.notify is true and email is absent. |
applicant.professionoptional | enum | Maps to a taxonomy code. GET /professions lists supported values. |
applicant.state, applicant.license_numberoptional | string | Prefills license. Improves the lookup when present. |
addresses.mailing, addresses.practiceoptional | object | Prefills addresses. Practice defaults to mailing. The applicant can edit. |
options.notifyoptional | boolean, default false | When true, SimpleNPI sends the link to the applicant by email or text. When false, you present applicant_link yourself and SimpleNPI contacts the applicant only for the one-time code on the hosted page. |
options.skip_lookupoptional | boolean, default false | Skips the pre-check. The lookup still runs after the applicant completes the page, so nothing is filed twice. |
/applications/{id}applications:readGET /applications/by-ref/{partner_ref}.{
"id": "app_8f2k…",
"partner_ref": "hw-48213",
"status": "issued",
"npi": "1234567893",
"issued_at": "2026-10-27T17:41:09Z",
"registry_record": { … },
"authorization_pdf_url": "https://…",
"pend_reason": null,
"eta": null,
"events": [
{ "at": "2026-10-27T17:00:00Z", "type": "awaiting_applicant" },
{ "at": "2026-10-27T17:33:12Z", "type": "ready" },
{ "at": "2026-10-27T17:33:40Z", "type": "filing" },
{ "at": "2026-10-27T17:41:09Z", "type": "issued" }
]
}GET /applications | List. Filters status, created_after, created_before. Cursor pagination: limit (max 100) and starting_after; response has has_more. |
POST /applications/{id}/cancel | Allowed while awaiting_applicant or ready. Otherwise 409 invalid_state. |
POST /applications/{id}/resend-link | Issues a new signed link and extends link_expires_at. Invalidates the previous link. |
DELETE /applications/{id}/applicant-data | Purges contact, address and prefill data now rather than on the retention schedule. Keeps the id, status, NPI, and the signed authorization CMS requires. Returns 204. |
GET /professions | Supported profession values and their taxonomy codes. |
Webhooks
Webhooks notify your endpoint when an application changes status. Payloads are thin by default: they identify the application and the new status and carry no applicant data. Fetch the full object with GET /applications/{id} over your authenticated connection.
Endpoints
| Endpoint | Notes |
|---|---|
POST /webhooks | Body {url, events, payload}. url must be HTTPS. events is a list or ["*"]. payload is "thin" (default) or "full". Returns the endpoint with a secret, shown once. |
GET /webhooks, DELETE /webhooks/{id} | List and remove endpoints. |
POST /webhooks/{id}/rotate-secret | Returns a new secret. The old one keeps verifying for 24 hours. |
POST /events/{event_id}/replay | Re-delivers one event to all matching endpoints. |
Event types
| Type | Sent when |
|---|---|
application.awaiting_applicant | Link created (only when options.notify is true, so you know the applicant was contacted). |
application.ready | Applicant completed and signed the hosted page. |
application.issued | CMS enumerated the NPI. |
application.pended | CMS asked for review. Also sent when pend_reason or eta change. |
application.failed | CMS rejected the application. |
application.expired | Link unused for 14 days. |
application.cancelled | Cancelled by you or by the applicant on the hosted page. |
your endpointapplication.issued{
"id": "evt_01J…",
"type": "application.issued",
"created_at": "2026-10-27T17:41:09Z",
"data": {
"application_id": "app_8f2k…",
"partner_ref": "hw-48213",
"status": "issued"
}
}
{
…,
"data": {
"application_id": "app_8f2k…",
"partner_ref": "hw-48213",
"status": "issued",
"npi": "1234567893",
"issued_at": "2026-10-27T17:41:09Z",
"registry_record": { … }
}
}Content-Type: application/json X-SimpleNPI-Event: application.issued X-SimpleNPI-Delivery: dlv_01J… X-SimpleNPI-Signature: t=1761586869,v1=<hex hmac-sha256>
const crypto = require('crypto'); function verify(rawBody, header, secret) { const { t, v1 } = Object.fromEntries( header.split(',').map(p => p.split('='))); if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(v1)); }
Delivery rules. Respond 2xx within 10 seconds; do your work asynchronously. Anything else, or a timeout, is retried at 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours, then the endpoint is marked failing and the technical contact is emailed. Deliveries are at-least-once and not guaranteed in order: dedupe on id and treat GET /applications/{id} as the source of truth for current status. Verify the signature over the raw request body before parsing JSON.
Hosted link
For partners that integrate without an API call. A link in your help center or onboarding flow opens the SimpleNPI hosted page with your partner id. No applicant data has to leave your systems. Optionally, a JWT signed with your link secret carries prefill and a partner_ref so the result can be reported back to you.
https://simplenpi.com/p/<partner_id> // blank form, attribution only https://simplenpi.com/p/<partner_id>?t=<jwt> // prefill and reference
| JWT claim | Notes |
|---|---|
iss | Your partner id. |
iat, exp | Issued and expiry. Maximum lifetime 7 days. |
jti | Unique per link. Replayed jti values are rejected. |
refoptional | Becomes partner_ref. Needed if you want a webhook or report for this applicant. |
applicant, addressesoptional | Same fields and rules as POST /applications. Every field optional. |
return_urloptional | Same rules as the API parameter. |
Algorithm HS256. The link secret is separate from the API key and may be placed in a server that only builds links. Without a webhook endpoint, results for a partner are available as a daily CSV export from the dashboard, containing partner_ref, status, NPI and issue date only.
Pop-up button
One script tag opens the hosted page in a window over your page and posts the result back to it. There is no iframe: the applicant's SSN, date of birth and birth state are entered on simplenpi.com only and never pass through your page or your servers. Works with no API key and no server-side change. Live now at https://simplenpi.com/embed.js; a demo page is at simplenpi.com/embed/.
<script src="https://simplenpi.com/embed.js"></script> <button type="button" data-simplenpi data-ref="hw-48213" // your id for this applicant; echoed back data-first="Jordan" data-last="Rivera" // optional prefill, every field optional data-state="UT" data-profession="Massage Therapist" data-fill="#npi"> // input that receives the NPI when issued Get an NPI </button>
| Attribute | Notes |
|---|---|
data-refoptional | Your applicant or onboarding id, up to 120 characters. Stored as partner_ref and returned in every event. Unsigned; treat it as a hint, and confirm server-side via webhook or GET /applications/{id} before relying on it. |
data-first, data-last, data-state, data-profession, data-email, data-mobileoptional | Prefill, same rules as POST /applications. The applicant can edit every field. |
data-filloptional | CSS selector. When the NPI exists the script sets that element's value (or text) and dispatches input and change. |
data-partneroptional | Your partner id, once issued. Attribution only. |
Programmatic form: SimpleNPI.open({ ref, first, last, state, profession, email, mobile }, { onDone, onIssued, onClose, fill }) returns { close, focus }.
Event (on window and on the button) | detail | Fires when |
|---|---|---|
simplenpi:done | { id, status, npi, statusUrl, ref } | The applicant submitted the form. status is ready (filing starts; number usually within 10 minutes), npi_exists (npi set, nothing filed) or review (a person is checking the profession). |
simplenpi:issued | { id, npi, ref, status } | The NPI exists. Immediately for npi_exists; otherwise when the status endpoint reports issued. |
simplenpi:closed | { id, ref } | The window closed. id is empty when nothing was submitted. |
- Window. 520 px wide, up to 820 px tall, centred over the opener. If the browser blocks pop-ups the form opens in a new tab and the events still fire; if that is blocked too, the current tab navigates to the form and the applicant gets the NPI by email and text.
- Message. The hosted page calls
postMessageon its opener with the opener's origin as the target, so no other window can receive it. Each open carries a random nonce that the result must echo. The script ignores messages from any origin other thanhttps://simplenpi.com. - Polling. After
simplenpi:donethe script readsGET /api/v1/applications/{id}?k=…(the applicant's own status key, public registry data only) every 20 seconds for up to 20 minutes, then stops. A webhook is the reliable path for the number; the browser event is a convenience for the page the applicant is looking at. - Storage. Nothing is written to cookies, localStorage or your DOM beyond the
data-filltarget. - Size. About 6 KB, no dependencies, ES5. Loads with
asyncordefer; the click handler is delegated, so buttons rendered later also work.
Testing
The sandbox is the same API at the sandbox base URL with test_ keys. Nothing is sent to CMS.
- Deterministic outcomes. The sandbox filer decides from the applicant's last name entered on the hosted page:
Issuedissues a test NPI (1999999xxx) 30 seconds afterready;Pendedpends withpend_reason: "name_mismatch"and issues after 5 minutes;Failedfails;Existsreturnsnpi_existson creation. Any other name issues. - Test SSNs. The hosted page accepts
000-xx-xxxxin the sandbox and rejects real-looking values. - Hosted page shortcut.
POST /applications/{id}/complete(sandbox only) fills and signs the hosted page as the applicant, so an integration test needs no browser. - Webhooks. Register a sandbox endpoint, or use the dashboard's request log and replay button. The signing scheme is identical to production.
- Rate limits are the same as production so a load test behaves the same way.
Downloads
Available with a sandbox key.
- OpenAPI 3.1 at
GET /v1/openapi.json, and a Postman collection generated from it. - Signature verification samples in Node, Python and Ruby.
- Data processing addendum with subprocessor list, retention schedule (the table in Data handling) and breach notification terms.
- Security questionnaire answers (SIG Lite format), W-9, and proof of insurance.
- Status page and changelog at
GET /v1/changelog.