# NAIC SBS Insurance Producer Scraper (`harvestloop/naic-sbs-licensee-scraper`) Actor

Look up and bulk-export insurance producer and licensee records from the NAIC State Based Systems public lookup. Covers all 34 SBS jurisdictions, individuals and business entities, and all license statuses including expired, revoked and cancelled.

- **URL**: https://apify.com/harvestloop/naic-sbs-licensee-scraper.md
- **Developed by:** [Gufran](https://apify.com/harvestloop) (community)
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## NAIC SBS Insurance Producer Scraper

Look up insurance producers, agents and agencies licensed across the **NAIC State Based Systems (SBS)** — the official registry used by 34 US states, the District of Columbia, Guam, Puerto Rico and the US Virgin Islands.

Three jobs, one Actor:

| Job | Mode | Requests |
| --- | --- | --- |
| **Verify one licence** — "is this agent actually licensed, and for what?" | `targeted` with an NPN or licence number | **1** |
| **Find a producer** — "every S-\* producer in New Jersey" | `targeted` with a name | 1–15 |
| **Bulk export** — "every producer and agency in Delaware" | `enumerate` | hundreds |

#### Coverage

| | |
| --- | --- |
| Jurisdictions | All 34 SBS participants |
| Individuals | Yes — producers, agents, brokers |
| Business entities | Yes — agencies, brokerages |
| Licence statuses | **All 14** — active, expired, revoked, cancelled, suspended, denied and more |
| Full detail | Optional — emails, websites, LOA qualifications, CE compliance, appointments |

***

### The problem this Actor solves

Most NAIC scrapers quietly lose data. Here's why, and what this one does instead.

The NAIC search API returns a **bare JSON array with no pagination** and a **hard cap of 25 rows per query**. It just truncates. NAIC's own website can't tell you the total either — it shows a warning that 25 results were returned, and that's all.

The usual workaround, searching single letters, still breaks. Last name `S`, first name `J` in New Jersey returns exactly 25 rows — and rows 26 onward are gone, with no error.

This Actor treats a full page as *"there is more behind this"*. It splits that query into finer partitions, re-runs each one, and repeats until nothing comes back truncated:

```
"S"  ->  25 rows (truncated)  ->  split into "SA" "SB" "SC" ...
"SB" ->   8 rows (complete)   ->  done
"SA" ->  25 rows (truncated)  ->  split further
```

When a partition is still capped at maximum depth and cannot be split again, the Actor **writes that exact prefix to a `gaps` list** in its key-value store. You always know what was missed. Nothing disappears quietly.

***

### Quick start

#### 1. Verify one producer by NPN — 1 request

The fastest path. An NPN is a unique national identifier.

```json
{ "jurisdictions": ["NJ"], "npn": 19623468, "enrichDetail": true }
```

#### 2. Find producers by name

```json
{ "jurisdictions": ["NJ"], "searchMode": "targeted", "lastName": "Smith" }
```

#### 3. Export a whole jurisdiction

```json
{
  "jurisdictions": ["DE"],
  "searchMode": "enumerate",
  "entityTypes": ["IND", "ENT"],
  "maxResults": 15000
}
```

#### 4. Everyone in one state who is *not* currently licensed

Compliance and lead-gen use case — licences that lapsed, were revoked, or were surrendered.

```json
{
  "jurisdictions": ["NJ"],
  "searchMode": "targeted",
  "lastName": "A",
  "licenseStatuses": ["CA", "REV", "EX", "SUS"]
}
```

***

### Choosing a mode

**Use `targeted` (default) whenever you have a concrete filter** — a name, an NPN, a licence number, a city, a zipcode. It is 1 to 15 requests and finishes in seconds.

**Use `enumerate` only when you want a whole population.** It walks the name space letter by letter, subdividing wherever it hits the cap. Expect hundreds or thousands of requests, and a runtime measured in minutes.

| You want | Mode |
| --- | --- |
| One specific person | `targeted` |
| Everyone matching a name prefix | `targeted` with `lastName` |
| Every licence in a state | `enumerate` |
| Only revoked/expired licences | `targeted` + `licenseStatuses` |
| Producers in one city | `targeted` + `businessCity` |

***

### How a run actually executes

Understanding this helps you pick sensible limits.

**`targeted`** — one API call per (jurisdiction × entity type × licence status). If you supply an NPN or licence number, that collapses to a single call regardless of how many statuses exist, because those identifiers are unique.

**`enumerate`** — starts with 26 partitions, one per last-name initial, per jurisdiction × entity type × status. Any partition returning a full 25-row page is subdivided and re-queued. The crawl converges until nothing truncates.

While it runs, the status line updates every 50 queries:

```
250 queries done - 120 unique licensees collected - 0 unresolved gap(s).
```

Duplicate licencees are detected and never charged twice, and `maxResults` is a hard cap — the Actor stops producing the moment it's reached.

#### Measured behaviour

Real runs of this Actor, so you can size your expectations:

| Run | Records | Wall time | Platform cost |
| --- | --- | --- | --- |
| NPN lookup + full detail | 1 | ~10 s | $0.004 |
| `targeted` name, 10 records | 10 | ~15 s | $0.003 |
| `enumerate` NJ, 500 records | 500 | ~60 s | $0.024 |

Cost per record works out to roughly **$0.00005–$0.00009**, dominated by request-queue writes from subdivision rather than compute.

***

### Input reference

#### Scope

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `jurisdictions` | array | `["NJ"]` | Two-letter SBS codes, or `ALL` for all 34. |
| `searchMode` | string | `targeted` | `targeted` = answer a question. `enumerate` = collect a population. |
| `entityTypes` | array | `["IND"]` | `IND` individual producers, `ENT` agencies and brokerages. Add both for full coverage. |
| `licenseStatuses` | array | `[]` = **all** | Leave empty to include every status. `A` active, `B` approved-not-active, `CA` cancelled, `EX` expired, `IN` inactive, `REV` revoked, `SUS` suspended, `DEN` denied, and others. Codes vary slightly by state. |
| `licenseTypeCodes` | array | `[]` = auto | Usually leave empty — the Actor detects each state's producer code. Common: `PRO`, `PAJ` public adjuster, `TPA`. |
| `loaTypes` | array | `[]` = all | Optional filter. `LLA` accident & health, `LLL` life, `LLC` casualty, `LLP` property. |

#### Search criteria — `targeted` mode only

Leave every field in this group empty when using `enumerate`.

| Field | Type | What it does |
| --- | --- | --- |
| `lastName` | string | Matches from the **start** of the name. Prefix match, not exact. |
| `firstName` | string | Prefix match. |
| `dbaLastName` | string | Searches assumed / trading names rather than the legal name. |
| `npn` | integer | Exact National Producer Number. Fastest single lookup. |
| `licenseNumber` | integer | Exact licence number. |
| `fein` | string | Federal Employer Identification Number. |
| `businessCity` | string | City of the business address. |
| `businessState` | string | State or province of the business address. |
| `businessZipcode` | string | Zipcode of the business address. |
| `mailingCounty` | string | County of the mailing address. |
| `residentLicense` | string | `Yes` for residents only, `No` for non-residents, empty for both. |

#### Output

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `enrichDetail` | boolean | `false` | Adds ~8 extra requests per record and returns emails, websites, a structured address, per-line qualification and exam dates, CE compliance, appointments, branch offices and the NAIC internal licence id. Off by default because it multiplies cost. |
| `maxResults` | integer | `1000` | Hard cap on unique licencees. The run stops the moment it's reached. |
| `maxRequests` | integer | `5000` | Hard ceiling on upstream API calls. In `enumerate` mode you can hit this before `maxResults`, because subdivision costs requests. |
| `concurrency` | integer | `8` | Parallel requests. Start here; raise only if a run feels slow. |

#### Proxy

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `useProxy` | boolean | **`true`** | Routes through Apify Proxy. Recommended for bulk runs. Turn it **off** for a one-off lookup to shave the cost — direct requests work unless NAIC answers 403. |
| `proxyConfiguration` | object | `{ "useApifyProxy": true }` | Advanced proxy settings. The group is deliberately left unset so Apify picks the cheapest one your plan includes. **Don't pin a group you aren't entitled to** — naming an unavailable one (e.g. `SHARED_DATACENTER`, a paid add-on) makes the run fail immediately. |
| `proxyCountry` | string | `US` | Proxy exit country. |
| `resume` | boolean | `true` | Reuse the checkpoint so an interrupted run continues instead of re-charging for records already collected. |

Proxy cost, measured on a 10-record run: **$0.0026** with the default, **$0.0025** with the proxy off, **$0.0047** if you force residential. Residential is billed at roughly 4x the datacenter rate and this API does not need it — reach for it only if you hit repeated 403s.

***

### Output

One dataset item per unique licensee. Empty values are `""`, never missing.

```json
{
  "licenseNumber": "3003526706",
  "naicLicenseId": null,
  "name": "AACH, CAYLEIGH JACLYN",
  "lastName": "AACH",
  "firstName": "CAYLEIGH",
  "middleName": "JACLYN",
  "licenseType": "Insurance Producer-Active",
  "licenseTypeCode": "PRO",
  "licenseStatus": "Active",
  "licenseEffectiveDate": "02/26/2025",
  "licenseExpirationDate": "04/30/2027",
  "linesOfAuthority": [
    { "line": "Accident & Health or Sickness", "grantedOn": "02/26/2025" },
    { "line": "Casualty", "grantedOn": "02/26/2025" },
    { "line": "Life", "grantedOn": "02/26/2025" },
    { "line": "Property", "grantedOn": "02/26/2025" }
  ],
  "naicProducerNumber": "19623468",
  "residentLicense": "No",
  "businessAddress": "BOYERTOWN, PA 19512",
  "businessAddressCity": "BOYERTOWN",
  "businessAddressState": "PA",
  "businessAddressZipcode": "19512",
  "businessPhone": "(610) 573-7339",
  "businessPhoneRaw": null,
  "fein": "",
  "dbaName": "",
  "designatedHomeState": "",
  "entityType": "IND",
  "jurisdiction": "NJ",
  "jurisdictionName": "New Jersey",
  "email": null,
  "sourceUrl": "https://external-lookup-web.prod.naic.org/solar-external-lookup/lookup/licensee/summary/3003526706?jurisdiction=NJ&entityType=IND&licenseType=PRO",
  "licenseManagerUrl": "https://external-lookup-web.prod.naic.org/solar-external-lookup/license-manager?entityType=IND&licenseNumber=3003526706&lastName=AACH&jurisdiction=NJ",
  "detail": null,
  "scrapedAt": "2026-10-02T15:23:50.531Z"
}
```

| Field | Notes |
| --- | --- |
| `licenseNumber` | The state licence number as issued. |
| `naicLicenseId` | NAIC's internal id. **Only set when `enrichDetail` is on.** |
| `name` / `lastName` / `firstName` / `middleName` | Raw name plus parsed components. |
| `licenseType` | Combined type and status, e.g. `Insurance Producer-Active`. |
| `licenseStatus` | Just the status: Active, Expired, Revoked, Cancelled… |
| `linesOfAuthority` | Every line held, each with the date it was granted. |
| `naicProducerNumber` | The NPN. |
| `residentLicense` | `Yes` or `No`. |
| `businessAddress*` | Pre-split into city / state / zipcode, so you don't have to parse it. |
| `businessPhone` | Formatted `(XXX) XXX-XXXX`. |
| `email` | **Only with `enrichDetail`.** |
| `sourceUrl` | Direct link to the official NAIC licensee page. |
| `licenseManagerUrl` | Direct link to the state's licence manager portal. |
| `detail` | **Only with `enrichDetail`.** Contains `licenses`, `dbaNames`, per-line `linesOfAuthority` (with `qualification`, `schoolCode`, `examCertDate`, `lineStatus`), `emails`, `phones`, `urls`, `demographics`, `continuingEducation`, `appointments`, `branchOffices`, `drlp`, `businessEntityAffiliations`, and a `detailErrors` list if any sub-resource failed. |

***

### Resuming an interrupted run

Every run writes a `CHECKPOINT_naic-sbs` record to the key-value store holding the licence numbers already collected, the partitions already completed, and any unresolved gaps.

Re-running the same input picks up where it left off. Completed partitions aren't re-run and already-collected licencees aren't charged again, so a resumed run only seeds what's missing. Keep `resume: true` (the default) if you expect long enumerations to get interrupted.

***

### Troubleshooting

**No results for a state I expected data from.** California, Texas, Florida, New York, Pennsylvania, Ohio, Georgia, Michigan and Washington license through their own separate systems and aren't part of NAIC SBS. They aren't accepted as jurisdiction codes, and the input schema says so.

**"The maximum number of results, 25, were returned" warnings.** Expected in `targeted` mode — it means that single query is genuinely larger than NAIC will return at once. Narrow the filter, or switch to `enumerate`, which subdivides and collects the full set.

**Worried about missed records?** Read `gaps` in the `CHECKPOINT_naic-sbs` key-value store record. If a prefix is listed there, those licencees couldn't be enumerated. An empty list means full coverage.

**Frequent 403s or timeouts.** Lower `concurrency` to 4, make sure `useProxy` is on, and consider a different `proxyCountry`.

**Run stopped early.** Check `maxRequests`. In `enumerate` mode, subdivision consumes requests quickly, so this ceiling can be reached before `maxResults`.

***

### Data source and attribution

Data comes from the public NAIC State Based Systems external lookup API at `api.prod.naic.org`. This Actor is an independent tool and is not affiliated with or endorsed by the NAIC. State licence records are public regulatory filings — you are responsible for your own compliance with applicable data-protection and fair-practice obligations.

### Changelog

See [CHANGELOG.md](./CHANGELOG.md).

# Changelog

This Actor's version history is a separate document: https://apify.com/harvestloop/naic-sbs-licensee-scraper/changelog.md

# Actor input Schema

## `jurisdictions` (type: `array`):

Pick one or more NAIC State Based Systems jurisdictions, or ALL for all 34. Note: California, Texas, Florida, New York, Pennsylvania, Ohio, Georgia, Michigan and Washington license through their own separate systems and are NOT part of NAIC SBS, so they are not accepted here.

## `searchMode` (type: `string`):

'targeted' answers one question. 'enumerate' collects a whole population and automatically subdivides queries to work around the NAIC 25-row cap.

## `entityTypes` (type: `array`):

IND covers individual producers. ENT covers business entities such as agencies and brokerages.

## `licenseStatuses` (type: `array`):

Leave empty to include every status. Common codes: A active, B approved but not active, CA cancelled, DEN denied, EX expired, IN inactive, REV revoked, SUS suspended. Codes vary slightly by jurisdiction.

## `licenseTypeCodes` (type: `array`):

Optional. Leave empty to auto-detect each jurisdiction's producer code. Common: PRO producer, PAJ public adjuster, TPA third party administrator.

## `loaTypes` (type: `array`):

Optional. Leave empty for all lines. Common: LLA accident and health, LLL life, LLC casualty, LLP property.

## `lastName` (type: `string`):

Matches from the start of the name. Use with searchMode 'targeted'.

## `firstName` (type: `string`):

Matches from the start of the name.

## `dbaLastName` (type: `string`):

Search assumed or trading names instead of the legal name.

## `npn` (type: `integer`):

Exact NPN lookup. The fastest way to resolve one known producer.

## `licenseNumber` (type: `integer`):

Exact license number lookup.

## `fein` (type: `string`):

Federal Employer Identification Number.

## `businessCity` (type: `string`):

Filter on the city of the business address.

## `businessState` (type: `string`):

Filter on the state or province of the business address.

## `businessZipcode` (type: `string`):

Filter on the business zipcode.

## `mailingCounty` (type: `string`):

Filter on the county of the mailing address.

## `residentLicense` (type: `string`):

Restrict to residents or non-residents.

## `enrichDetail` (type: `boolean`):

Adds roughly 8 extra requests per record and returns emails, websites, a structured address, per-line qualification and exam dates, continuing education compliance, appointments, branch offices and DBA names.

## `maxResults` (type: `integer`):

Stop after this many unique licensees.

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

Hard ceiling on upstream requests. Enumeration subdivides queries, so this can be reached before maxResults is.

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

Parallel requests. Start low and raise it if the run is slow.

## `resume` (type: `boolean`):

Reuse the checkpoint in the key-value store so an interrupted run continues instead of re-charging for records already collected.

## `useProxy` (type: `boolean`):

Route requests through Apify Proxy. Recommended for bulk runs, because NAIC throttles and blocks individual IP addresses. Turn it OFF for a one-off targeted lookup to keep the run as cheap as possible - direct requests are fine unless NAIC answers 403.

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

Apify Proxy settings, used only when 'Use a proxy' is on. The default leaves the proxy group unset so Apify routes through the cheapest group your plan includes. Do not pin a group unless you know you are entitled to it - pinning an unavailable group (for example SHARED\_DATACENTER, which is a paid add-on) makes the run fail immediately. Choose RESIDENTIAL only if you hit repeated 403 errors; it is billed at roughly 4x the datacenter rate.

## `proxyCountry` (type: `string`):

Two letter code for the proxy exit country. Used only when "Use a proxy" is on and no explicit proxy configuration is supplied.

## Actor input object example

```json
{
  "jurisdictions": [
    "NJ"
  ],
  "searchMode": "targeted",
  "entityTypes": [
    "IND"
  ],
  "licenseStatuses": [],
  "licenseTypeCodes": [],
  "loaTypes": [],
  "npn": 19623468,
  "licenseNumber": 3003526706,
  "residentLicense": "",
  "enrichDetail": false,
  "maxResults": 1000,
  "maxRequests": 5000,
  "concurrency": 8,
  "resume": true,
  "useProxy": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "proxyCountry": "US"
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing one item per unique licensee

## `checkpoint` (type: `string`):

Key-value store record holding collected license numbers, completed partitions and any unresolved gaps

# 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 = {
    "jurisdictions": [
        "NJ"
    ],
    "entityTypes": [
        "IND"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("harvestloop/naic-sbs-licensee-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 = {
    "jurisdictions": ["NJ"],
    "entityTypes": ["IND"],
}

# Run the Actor and wait for it to finish
run = client.actor("harvestloop/naic-sbs-licensee-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 '{
  "jurisdictions": [
    "NJ"
  ],
  "entityTypes": [
    "IND"
  ]
}' |
apify call harvestloop/naic-sbs-licensee-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,harvestloop/naic-sbs-licensee-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/BPA5MPgzWMWTVnqmT/builds/kftIAexYDqvAx1kBY/openapi.json
