# Colorado DORA Professional License Status Delta (`titan_coder/co-dora-professional-license-status-delta`) Actor

Tracks Colorado DORA's statewide professional license registry (nurses, cosmetologists, plumbers, engineers, 300+ license types) and alerts only on new licenses, status changes (active/expired/revoked/suspended), and confirmed removals since your last check.

- **URL**: https://apify.com/titan\_coder/co-dora-professional-license-status-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 16.7% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 license status changeds

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?

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

## Colorado DORA Professional License Status Delta

Durable monitor for Colorado's official statewide **Professional and Occupational Licenses** registry
(Colorado Department of Regulatory Agencies / DORA) — one dataset covering **300+ license types** in a
single file: registered nurses, cosmetologists, plumbers, real estate brokers, professional engineers,
CPAs, jewelers, architects, and many more. Watch one license type at a time and get notified only about
genuinely new licenses, status changes (Active/Expired/Revoked/Suspended/Surrendered/Summary Suspension/...),
and confirmed removals since your last check. No API key needed, no login, no captcha.

### Source

`https://data.colorado.gov/resource/7s5z-vewr.json` — Socrata SODA JSON API for dataset `7s5z-vewr`
("Professional and Occupational Licenses in Colorado"), confirmed live 14.09.2026: **1,608,059 rows**,
**313 distinct license type codes**, updated daily (`data_updated_at` one day before verification). The
disciplinary pool (Revoked + Suspended + Surrendered + Voluntary Surrender + Summary Suspension) is
**18,283** historical cases, with roughly 187 new discipline-dated records per month.

**This is a large, single-snapshot registry — not an incremental feed.** There is no field for "changed
since date X" and no cursor; the only honest way to catch a status change is to re-fetch the full current
population of one license type and diff it against the last known state. That is exactly what this actor
does, server-side filtered per watch (`$where=licensetype='...'`), not a client-side filter over the whole
1.6M-row file — the registry is too large to cache fully in one run (see ROADMAP.md for the full sizing
reasoning).

### How it works

1. Each `watch` names one `licenseType` — DORA's own short code for a license category, e.g. `RN`
   (Registered Nurse), `COS` (Cosmetologist), `PE` (Professional Engineer), `REG` (Real Estate Broker). See
   [How to find a license type code](#how-to-find-a-license-type-code) below.
2. Every run for that watch downloads the **entire current population of that one license type in a single
   HTTP request** (server-side `$where=licensetype='...'`, no pagination — see Honest limits) — not a
   truncated window. Watches sharing the same `licenseType` in one run reuse a single fetch.
3. A stable identity is kept per row using Socrata's own row identifier (`:id`), plus a content hash of the
   substantive fields (status, dates, discipline case number/action/dates, license number, subcategory). A
   row's cosmetic fields (name, city, state, zip) never trigger a billed event on their own.
4. Genuinely new rows, status/discipline changes on an already-known row, and **confirmed** removals (a row
   missing from **two consecutive** checks — see below) are pushed to the dataset and billed once each
   (`license-status-changed`). A check that finds nothing new costs nothing beyond the fixed platform run
   cost.

#### Why two consecutive misses before "removed"

A single check where a previously-known row is not found in the current snapshot is structurally
indistinguishable from a transient snapshot problem (a page request that failed to complete, a momentary
source hiccup). Reporting it as "removed" on the first miss would risk a false alarm. The first miss is
recorded internally (`missingStreak`) but never billed; only a **second consecutive** miss — meaning the
row was absent on two separate runs in a row — is billed as a confirmed `removed` event. If the row
reappears at any point before that second miss, the streak resets with no event at all; if it reappears
*after* being confirmed removed, that is honestly billed again as `reappeared`.

### Input

```json
{
  "monitorId": "my-co-dora-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    { "watchId": "cosmetology", "licenseType": "COS" },
    { "watchId": "nurses", "licenseType": "RN" }
  ],
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

`userAgentContact` is sent as a descriptive `User-Agent` on every request (polite-scraping practice) —
Socrata's open-data API does not require a key, but we still identify ourselves.

Add more watches later under the same `monitorId` — each watch keeps its own independent history. A
`watchId` is permanently bound to the `licenseType` it first saw; pointing the same `watchId` at a different
license type later fails the run instead of silently mixing histories.

Pick a **narrow** license type for faster, cheaper runs. Live sizes (14.09.2026, top types): `RN` 238,749,
`NA` 195,495, `APE` 92,052, `COS` 77,197, `DR` 69,185, `PE` 63,335, `PN` 50,763, `AP` 41,364, `CPA` 39,545,
`JW` 33,044 — but the large majority of the 313 codes are small (dozens down to a single row).

#### How to find a license type code

Browse or query the source directly, e.g.:

```
https://data.colorado.gov/resource/7s5z-vewr.json?$select=licensetype,count(*)&$group=licensetype&$order=count(*)%20DESC
```

or look up the DORA "Lookup a License" page for the profession you care about and note the two-letter/code
prefix shown against similar license numbers.

### Output row (per change)

`watchId, changeType ("new"|"changed"|"reappeared"|"removed"), stableRowId, licenseType, licenseNumber,
subCategory, subjectType ("individual"|"business"|"unknown"), lastName, firstName, middleName, suffix,
entityName, city, state, mailZipCode, licenseStatus, previousStatus, licenseFirstIssueDate,
licenseLastRenewedDate, licenseExpirationDate, specialty, title, degrees, caseNumber, programAction,
disciplineEffectiveDate, disciplineCompleteDate, verifyLicenseUrl, healthcareProfileUrl, monitorId, runId,
discoveredAt, eventId, billed`

For `changeType: "removed"`, the row's current-snapshot fields (`licenseNumber`, `licenseStatus`, name
fields, etc.) are `null` by design — the row is no longer in the source, so there is nothing current to
report beyond `previousStatus` (the last known status before it disappeared) and `licenseType` (the watch's
own filter, always known). See Honest limits.

### Billing

Pay-per-event: `license-status-changed` — charged once for each row genuinely new, status/discipline-changed,
reappeared, or confirmed-removed since the previous check of that watch. The first check of a new watch
establishes a baseline (no charge). Failed/blocked checks are never charged.

### Important — read before relying on this for hiring/compliance/vendor decisions

**This actor is an informational monitor of the Colorado DORA public license registry — it is NOT a
real-time regulatory feed, NOT a background-check report, and NOT legal advice.** Always confirm directly
with the relevant DORA board, or the `verifyLicenseUrl` in the row itself, before acting on any single entry
for employment, licensing, or compliance decisions.

### Delivery guarantee: at-most-once (we would rather lose an alert than bill you twice)

Each computed change is delivered to the dataset and charged **at most once**, for as long as the monitor's
claim log exists (see the boundary below). Before any irreversible step (writing the row, charging the
event) the run takes an **atomic claim** on that exact change, using the only atomic primitive the Apify
platform offers: a request queue's unique-key insert. Exactly one run can win that claim for a given change.
The claim log is never consumed, deleted or rotated by this actor; it is a permanent record of what was
already attempted, and `coverage.claimJournalSize` reports its size each run so you can watch it grow (the
platform's counter is eventually consistent, so treat it as a lagging estimate, not an exact count).

**Where that guarantee ends — the honest boundary.** The claim log lives in a *named* request queue
(`<prefix>-<monitorId>-claims`) in your own account. The at-most-once guarantee holds as long as that queue
keeps existing. If you — or any process holding your account credentials — delete, rename or re-create it
from the Console or API, the log starts empty and previously delivered changes can be delivered and charged
again. That is the unavoidable boundary of *any* durable storage, not a loophole in the protocol. For the
same reason, the actor's storage prefix and internal claim namespace are frozen after release: changing
either would create a fresh, empty log with exactly the same effect.

The response the platform returns for each claim is interpreted **strictly**: only a real boolean `false`
grants the right to write and charge, only a real boolean `true` denies it, and anything else — a missing
field, `null`, `0`, an empty string, a changed SDK response shape — aborts the run's delivery for that item
with `claim_protocol_error` **before** any row or charge. An answer we do not fully understand is never
read as "you may charge".

One thing we deliberately do **not** claim: the monitor's lease makes overlapping runs a fail-closed
exception rather than a fact of life, but between the moment a run verifies it still holds the lease and
the moment the dataset write or charge actually lands there is an unavoidable time gap (the platform offers
no fencing token for datasets or billing). So "a run that lost the lease can never write another row" would
be an overstatement. What actually protects your money is the claim above: the key is already taken, so
even a ghost run cannot charge for the same change twice.

The honest consequence, stated plainly: **if a run dies after taking the claim but before finishing, that
one change is lost**. It is recorded as `dataset_unknown` or `charge_unknown` and it is **not** re-delivered
on the next run. We deliberately chose possible loss of one alert over the possibility of charging you
twice for the same event. This is *at-most-once* delivery, not *exactly-once*; any actor that claims
exactly-once over a store without compare-and-swap is overstating what the platform can do.

Practically this only happens if the Apify run is killed mid-delivery (platform abort, timeout, migration).
Every such case is visible: the run's `coverage` and `run_summary` report it, and `run_summary.eventsBilled`
plus Apify's own billing ledger remain the source of truth for what you actually paid for. Every early exit
(lease busy, claim-protocol error, or a deadline hit before any watch was even attempted) still writes a
`coverage`/`digest` record to the key-value store, so a `FAILED` run is never silent about why.

### Run timeout — cloud platform limit vs. the actor's own deadline

The actor's own internal worst-case deadline (`src/config.py::UPPER_TIME_S * DEADLINE_MARGIN_MULT` =
2,415s × 1.1 = **2,656.5s ≈ 44min**, covering `MAX_WATCHES_PER_RUN=5` watches each doing exactly two HTTP
requests — one `count(*)` and one single-request snapshot fetch — with one retry per request) is only
meaningful if the Apify platform actually lets the run live that long. Left unset, Apify silently applies its
own default run timeout (**3,600s / 1 hour**, confirmed live 14.09.2026) — close to but still short of the
actor's own worst case with margin, which would risk killing the run before its own degradation/reconcile
logic (`_reconcile_if_deadline_passed`, the webhook time-budget check) ever gets a chance to run honestly.
`.actor/actor.json` therefore sets `defaultRunOptions.timeoutSecs` explicitly to **4,000s (~67min)**. That
number is checked against `src/config.py::REQUIRED_CLOUD_TIMEOUT_SECS` = 3,256s (the actor's own deadline
plus `lease.py`'s `LEASE_DEADLINE_MARGIN_S` = 600s lease-release margin) by both a module-level `assert` in
`config.py` and `tests/test_main.py::test_actor_json_declares_explicit_timeout_covering_worst_case_with_margin`
— the two numbers (config's requirement and actor.json's actual value) are kept in sync by test, not by eye.
This dropped by more than 5x from the previous offset-pagination architecture (was ~5h12m/21,600s) because a
single-request fetch replaces up to 84 paginated HTTP requests per watch with exactly one.

### Honest limits

- **Row-level identity is Socrata's own `:id` field, not `licenseNumber` + `licenseType`.** A live check
  before building this actor found real collisions within a single `licenseType` (the same `licenseNumber`
  appearing under multiple rows — up to 8 rows for one number under `COS`), so the license number/type pair
  cannot be trusted as a unique row identity. `:id` is Colorado DORA's Socrata-assigned surrogate row key
  (`rowIdentifierColumnId` is `null` for this dataset, confirmed live) and is the only field this actor
  treats as identity. `licenseNumber`/`licenseType` remain delivered as context fields on every row.
- **`licenseNumber` is legitimately absent for a small number of rows** (374 of 1,608,059 live, mostly
  temporary/local permits without an assigned number) — this is not treated as a structural error.
- **Full-snapshot integrity via a single request, not paginated.** Each watch's fetch is one HTTP GET
  (`$where=licensetype='...'`, `$order=:id`, a single large `$limit`) over a live, mutable registry — not
  offset-pagination across multiple HTTP requests. This is a deliberate architectural choice, not just an
  optimization: a single SQL query executes under the source database's own snapshot isolation, so the set of
  rows visible to that one request is fixed at the moment the query starts and cannot be partially mutated
  while the response streams — a row is either wholly present (if it existed at query start) or wholly
  absent, never half-read-then-changed. Multiple separate HTTP requests (the old page-by-page design) cannot
  offer that guarantee in principle, since each request only sees its own snapshot at its own start time, and
  the source can mutate freely in the window between requests — including strictly *inside* an already-read
  page's range, which a purely page-boundary check (row counts, adjacent-page overlap) cannot detect. The one
  remaining integrity check is: the single response's row count must **exactly** match what an independent
  `$select=count(*)` (taken immediately before the fetch) says the registry holds for that license type — not
  just "not fewer": a response returning *more* rows than expected is treated exactly as seriously as one
  returning fewer. A duplicate `:id` *within* that one response is also treated as a structural anomaly. If
  the registry has grown past the single request's configured `$limit` ceiling (`SODA_MAX_LIMIT`), the run
  fails closed *before* even issuing the fetch, rather than silently accepting a `$limit`-truncated partial
  response as if it were the whole snapshot. None of these checks invent or drop data silently; all report the
  run honestly (`source_access_limited`) instead.
- **The durable index has no capacity limit and never evicts old entries.** Every `:id` ever seen for a
  watch stays in that watch's durable index indefinitely (a plain, unbounded map, not a fixed-size or
  FIFO-capped list) — an entry that fell out of a capped window could otherwise come back as "never seen
  before" and be billed a second time for a row that existed before monitoring started. The only limit is on
  how many *new/changed/removed* transitions are processed for billing in a single run
  (`MAX_NEW_ITEMS_PER_WATCH_PER_RUN` = 300) — any excess is simply picked up on the next run, never dropped.
- **A whole-registry ID re-issue (rekey) is detected and treated as a run failure, never as a mass delta.**
  Socrata occasionally republishes a dataset with a brand-new internal `:id`/`tableId` space on a structural
  change; if that ever happens to `7s5z-vewr`, every previously known row would look "removed" and every
  current row would look "new" in the same run — the existing per-run page-integrity checks above don't
  catch this because they only verify one run's snapshot, not continuity between runs. Two independent
  fail-closed checks guard against silently mass-billing that as a normal delta: (1) each run best-effort
  reads Socrata's own dataset metadata (`GET /api/views/7s5z-vewr.json`, field `tableId`) and compares it to
  the value stored from the previous run for that watch — a change is treated as a confirmed re-issue signal
  on its own; (2) independently, if 50% or more of a watch's previously known rows are missing from the
  current snapshot in a single run (and that watch had at least 20 known rows, to avoid false positives on
  tiny watches), that is treated the same way. Either check tripping stops that watch for the run with
  `rekey_suspected` — no rows delivered, no charge, and the watch's durable index is **not** updated, so the
  next run re-checks against the same known-good baseline instead of losing it. This is the same class of
  outcome as `source_access_limited` above (a structural anomaly, not a transient network hiccup).
- **No server-side filter exists for anything narrower than one license type.** `licenseType` is the only
  filter this actor supports, applied server-side; there is no per-watch filter on license number, name, or
  status — watch one license type and read the delivered rows for what you care about within it.
- Nullable billing-relevant fields (status, dates, discipline fields) are validated by **physical key
  presence** in the source's own `X-SODA2-Fields` response header, not just "value is not null" — Socrata
  omits a null field's key entirely, so the only honest way to tell "this field is legitimately empty on
  this row" from "this column disappeared from the schema" is the header, checked on every single fetch,
  independent of whether any row in that response happens to have a value.
- We don't invent data: if the API's response shape changes, a required field is empty/whitespace-only where
  it should not be, or the registry has grown past the single request's `$limit` ceiling, the run reports it
  honestly (`structure_changed: ...` → `source_access_limited`) instead of silently returning zero results
  or a truncated partial snapshot.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars).

## `userAgentContact` (type: `string`):

Sent as a descriptive, identifiable User-Agent on every request to data.colorado.gov (polite-scraping practice) — the Socrata open-data API does not require a key, but we still identify ourselves. Give your email or a contact link. Not a secret.

## `watches` (type: `array`):

1-5 objects: {"watchId": "cosmetology", "licenseType": "COS"}. licenseType is DORA's own short code for one license category (e.g. RN = Registered Nurse, COS = Cosmetologist, PE = Professional Engineer, REG = Real Estate Broker — see README for the full list of 300+ codes and how to look one up). Each watch tracks the FULL current population of that one license type and reports every new license, every status change (Active/Expired/Revoked/Suspended/Surrendered/...), and every confirmed removal. New watches can be added later under the same monitorId. Pick a narrow license type for faster, cheaper runs — the largest types (RN, NA) have 100,000+ rows.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when new billed changes were delivered; always — post it every run; never — do not call webhookUrl at all.

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

Optional. Receives a digest of delivered (billed) license status changes as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-co-dora-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "podiatry",
      "licenseType": "POD"
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, changeType (new/changed/reappeared/removed), stableRowId, licenseType, licenseNumber, licenseStatus, previousStatus, subjectType, lastName/firstName/entityName, verifyLicenseUrl.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch status and reason, snapshot row counts, new/changed/removed counts, and how many rows were billed. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# 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 = {
    "monitorId": "my-co-dora-watch",
    "userAgentContact": "you@example.com",
    "watches": [
        {
            "watchId": "podiatry",
            "licenseType": "POD"
        }
    ],
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/co-dora-professional-license-status-delta").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 = {
    "monitorId": "my-co-dora-watch",
    "userAgentContact": "you@example.com",
    "watches": [{
            "watchId": "podiatry",
            "licenseType": "POD",
        }],
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/co-dora-professional-license-status-delta").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 '{
  "monitorId": "my-co-dora-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "podiatry",
      "licenseType": "POD"
    }
  ],
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/co-dora-professional-license-status-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/co-dora-professional-license-status-delta"
        }
    }
}
```

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/YhM1j6AXJnrjPUMZj/builds/GqlGI2bgxhcyjOF4g/openapi.json
