# License Verification & Expiry Monitor: Bulk US License Lookup (`fayoussef/license-roster-monitor`) Actor

Re-verify a whole roster of contractors, nurses, agents or providers against official state registries, then get only what changed: expired, suspended, revoked, lapsed bond, about to renew. Official open data, no API key, no portal scraping.

- **URL**: https://apify.com/fayoussef/license-roster-monitor.md
- **Developed by:** [Youssef Farhan](https://apify.com/fayoussef) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.80 / 1,000 license verifieds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

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

## License Verification & Expiry Monitor: Bulk US License Lookup

Bulk license verification and expiration monitoring for a whole roster of contractors, nurses, agents, therapists and healthcare providers. Upload the licensees you are responsible for, and this Actor checks every one of them against the official state registry, then tells you which are expired, suspended, revoked, about to lapse, or no longer in the register at all.

Run it once for an audit file. Put it on a monthly schedule and it becomes a compliance monitor: from the second run on, it can return **only what changed** since last time.

### Why a roster monitor and not another license lookup

Most license tools answer one question: is this license valid right now. Almost nobody has that question. What a staffing agency, an insurer, a home services marketplace or a credentialing team has is a list of a few hundred licensees and a contractual duty to re-check all of them on a cycle, forever.

That is what this Actor is shaped around:

- **The roster is the input.** One line per licensee, checked in a single run.
- **It remembers.** Each roster keeps its own baseline, so run ten tells you the four rows that moved, not the same four hundred rows again.
- **It never guesses in the reassuring direction.** Where a register publishes no status column, the result says the status was derived from the expiry date. Where a match is weak, it is reported as not found rather than as somebody else's license.

### Registries covered

Every source is an official government open-data endpoint. No API key, no account, no login, and nothing that breaks when a state redesigns its lookup portal.

| Registry | Covers | Notes |
|---|---|---|
| Washington L\&I | Contractor licenses | Status, expiry, business type, principal |
| Oregon CCB | Construction contractors | Also **bond and insurance** expiry |
| Texas TDLR | All TDLR programs: electricians, HVAC, cosmetology, barbers and more | Published without a status column, so status is derived from the expiry date |
| Colorado DORA | Professional and occupational licenses: nurses, dentists, engineers, CPAs and more | Full status vocabulary including revoked and surrendered |
| Colorado DORA | Licensed real estate professionals | Separate register, searched alongside the one above |
| Delaware | Professional and occupational licensing | Includes a disciplinary-action flag |
| NPPES (nationwide) | Every US healthcare provider with an NPI | All 50 states, by NPI or by name |

Need a state that is not here? [Suggest it](https://automationbyexperts.com/apify) and it can be added.

### What you get back

One row per roster line, with the registry's own fields carried through alongside the verdict:

| Field | What it is |
|---|---|
| `verification` | `verified`, `probable_match` or `not_found` |
| `matchConfidence` / `matchReason` | How sure the match is, and why that row was chosen |
| `licenseStatus` | Normalised: `active`, `expired`, `inactive`, `revoked`, `suspended`, `surrendered`, `closed`, `unknown` |
| `licenseStatusRaw` / `statusSource` | Exactly what the register said, and where the verdict came from |
| `expirationDate` / `daysToExpiry` | Expiry, and how many days are left (negative once lapsed) |
| `isExpired`, `isExpiringSoon`, `isAdverse` | Ready-made flags to filter or route on |
| `alerts` | Plain-English reasons this licensee needs attention |
| `_change` | `baseline`, `new`, `changed` or `unchanged` since the previous run |
| `bondExpirationDate`, `insuranceExpirationDate` | Oregon only, with their own day counts and alerts |
| `verifyUrl` | The official page to confirm on, for your audit file |

### How to write your roster

One licensee per line. The state code goes first, then the license number, then optionally the name you have on file:

```
WA | ECOSTSC758NN | Eco Star C G Construction LLC
OR | 240438
TX | 3365
CO | 711991
DE | RS-0037350
US | 1467560003
```

The name is worth adding. It is what lets the Actor tell you that a license number is live but now belongs to a different company, which is a failure mode a number-only check cannot see.

You can also search by name instead of number, and a line with no state code is looked up in every registry you selected.

### Input

| Field | Type | What it does |
|---|---|---|
| `roster` | array | The licensees to check, one per line |
| `registries` | array | Which registers to search. All of them by default |
| `searchBy` | select | Automatic, license number, or name. Automatic suits almost every roster |
| `expiringWithinDays` | integer | How much notice you want before a license lapses. Default 60 |
| `alertsOnly` | boolean | Return only the licensees that need attention |
| `onlyChangesSinceLastRun` | boolean | Return only what moved. This is what makes it a monitor |
| `trackChanges` | boolean | Keep a baseline between runs. On by default |
| `watchlistName` | string | One history per roster, so "nurses" and "electricians" stay separate |
| `maxRosterEntries` | integer | Safety cap per run |
| Alerts | strings | Slack, Discord, Telegram or a webhook, all optional |

#### Example input

```json
{
    "roster": [
        "WA | ECOSTSC758NN | Eco Star C G Construction LLC",
        "OR | 240438",
        "TX | 3365",
        "US | 1467560003"
    ],
    "expiringWithinDays": 90,
    "alertsOnly": true,
    "onlyChangesSinceLastRun": true,
    "watchlistName": "field-technicians"
}
```

#### Example output

```json
{
    "rosterInput": "TX | 3365",
    "verification": "verified",
    "matchConfidence": 0.95,
    "matchReason": "license number matches exactly",
    "matchedName": "COOK, ADRIAN P",
    "matchedLicenseNumber": "3365",
    "licenseType": "A/C Technician",
    "licenseStatus": "expired",
    "statusSource": "derived from expiration date",
    "expirationDate": "2025-06-30",
    "daysToExpiry": -447,
    "isExpired": true,
    "alerts": ["License is expired"],
    "_change": "changed",
    "registry": "Texas TDLR licenses (all programs)",
    "registryState": "TX"
}
```

### Common questions

**How do I monitor license expirations automatically?**
Schedule this Actor monthly or weekly with `onlyChangesSinceLastRun` turned on and a Slack or webhook URL filled in. A quiet month sends nothing; the month a license lapses, you get a message naming the licensee.

**Can I verify contractor licenses in bulk?**
Yes. That is the normal mode. One run takes a roster of hundreds of license numbers and returns a verdict for each.

**Does it check bond and insurance?**
For Oregon CCB, yes, including their expiry dates and their own alerts. The other registers do not publish bond data.

**What happens if a licensee is not in the register?**
It comes back as `not_found` with an alert. For Oregon, which publishes active licenses only, that specifically means not currently active. The result says so on the row.

**How accurate is the name matching?**
Names are compared word by word with legal forms such as LLC and Inc ignored, so "Smith Plumbing LLC" matches "SMITH PLUMBING INC". A weak match is reported as not found rather than presented as a verified result, because putting a stranger's license next to your contractor is the one mistake a compliance tool cannot make.

**Is a proxy needed?**
No. Every source is an official open-data API.

### Pricing

This Actor is billed per event, plus whatever your Apify plan covers. The events are:

| Event | What triggers it |
|---|---|
| Actor start | Each run starts |
| License verified | Each roster line a registry actually answered for |
| Notification sent | Each Slack, Discord, Telegram or webhook delivery |

Two things you are deliberately **not** charged for:

- **Alerts are free.** An expired licensee is a line you already paid to verify,
  so it is not billed twice. A roster in bad shape does not cost more than a
  clean one.
- **A registry that did not answer is not billed.** If every register a line
  belongs to failed to respond, you learned nothing about that licensee, so the
  line is not charged. A register that answered "no such license" did the work
  and is charged.

The current rates are shown on this Actor's Store page. Free-plan runs are capped at a small roster so you can try the output shape before subscribing; an [Apify plan](https://apify.com/pricing?fpr=youssef) lifts the cap.

### Limits worth knowing

- Coverage is the seven registers in the table above, not all fifty states. What is covered is covered completely; nothing is faked for the rest.
- Texas and Oregon publish extracts without a status column. Status there is derived from the expiry date, and every row says so.
- State open-data extracts are refreshed on the state's own schedule, so a licence renewed this morning may still read as expiring for a day or two.
- Change tracking is keyed on the watchlist name. Renaming it starts a fresh baseline.

### Need something else?

Another state, another profession, a different output shape, or this wired into your own system: youssefarhan24@gmail.com

# Actor input Schema

## `roster` (type: `array`):

One licensee per line. Write the state code, then the license number, then optionally the name you have on file: WA | ECOSTSC758NN | Eco Star C G Construction LLC. The name is what lets the Actor tell you when a license number belongs to somebody else. A line with no state code is looked up in every registry selected below, which works but costs more requests. You can also search by name instead of by number.

## `registries` (type: `array`):

Which official registries to look in. Leave all of them on and each line is matched wherever its state code points. Every one of these is a government open-data source and needs no account or key.

## `searchBy` (type: `string`):

Leave this on automatic unless your roster is unusual. Automatic treats a mostly numeric line as a license number and anything else as a name, then scores the match either way, so a wrong guess costs one extra lookup and never a wrong answer.

## `expiringWithinDays` (type: `integer`):

How much notice you want on a license that is still valid but about to lapse. Set it to the length of your renewal process: 60 days is enough to chase a contractor twice.

## `alertsOnly` (type: `boolean`):

Leave this off to get your whole roster back with a verdict on each line, which is what an audit file needs. Turn it on to get only the expired, lapsing, revoked, suspended and not-found lines, which is what a weekly review needs.

## `onlyChangesSinceLastRun` (type: `boolean`):

The point of running this on a schedule. Turn it on and a quiet month produces an empty dataset instead of the same roster again. The first run always returns everything, because nothing has changed yet.

## `trackChanges` (type: `boolean`):

Keeps a small fingerprint of each licensee's status and expiry so the next run can tell you what moved. Turn it off for a one-off check that should leave nothing behind.

## `watchlistName` (type: `string`):

The name of the history this run compares against. Use a different name per roster, for example "electricians" and "nurses", and each keeps its own baseline. Changing this name starts a fresh history, so the next run reports everything as new.

## `maxRosterEntries` (type: `integer`):

A safety cap on how much of the roster one run will check. Lines beyond it are skipped and the run says so.

## `slackWebhookUrl` (type: `string`):

Optional. Post the licensees that need attention to a Slack channel. Nothing is sent when nothing needs attention, so a quiet channel means a clean roster.

## `discordWebhookUrl` (type: `string`):

Optional. Post the same digest to a Discord channel.

## `telegramChatId` (type: `string`):

Optional. The chat to send the digest to. Fill this in together with the bot token below.

## `telegramBotToken` (type: `string`):

Optional. Your own bot's token, used only to deliver the digest to the chat above.

## `webhookUrl` (type: `string`):

Optional. Receive the alerts as JSON, for Zapier, Make, n8n or your own endpoint.

## Actor input object example

```json
{
  "roster": [
    "WA | ECOSTSC758NN | Eco Star C G Construction LLC",
    "WA | 1AQUAAM831PC",
    "OR | 240438",
    "TX | 3365",
    "CO | 711991",
    "DE | RS-0037350",
    "US | 1467560003"
  ],
  "registries": [
    "wa-contractor",
    "or-ccb",
    "tx-tdlr",
    "co-professional",
    "co-real-estate",
    "de-professional",
    "npi"
  ],
  "searchBy": "auto",
  "expiringWithinDays": 60,
  "alertsOnly": false,
  "onlyChangesSinceLastRun": false,
  "trackChanges": true,
  "watchlistName": "default",
  "maxRosterEntries": 500
}
```

# Actor output Schema

## `results` (type: `string`):

One row per licensee: registry status, expiry, days left, alerts, and whether anything moved since the last run.

## `attention` (type: `string`):

Only the rows carrying a compliance alert: expired, lapsing, revoked, suspended or not found.

## `summary` (type: `string`):

Counts of verified, not found, alerted and changed licensees, plus which registries were searched.

# 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 = {
    "roster": [
        "WA | ECOSTSC758NN | Eco Star C G Construction LLC",
        "WA | 1AQUAAM831PC",
        "OR | 240438",
        "TX | 3365",
        "CO | 711991",
        "DE | RS-0037350",
        "US | 1467560003"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fayoussef/license-roster-monitor").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 = { "roster": [
        "WA | ECOSTSC758NN | Eco Star C G Construction LLC",
        "WA | 1AQUAAM831PC",
        "OR | 240438",
        "TX | 3365",
        "CO | 711991",
        "DE | RS-0037350",
        "US | 1467560003",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("fayoussef/license-roster-monitor").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 '{
  "roster": [
    "WA | ECOSTSC758NN | Eco Star C G Construction LLC",
    "WA | 1AQUAAM831PC",
    "OR | 240438",
    "TX | 3365",
    "CO | 711991",
    "DE | RS-0037350",
    "US | 1467560003"
  ]
}' |
apify call fayoussef/license-roster-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fayoussef/license-roster-monitor"
        }
    }
}
```

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/zczHbJchbesdSFyzX/builds/0zKbogEESWWCZDm2H/openapi.json
