Colorado DORA Professional License Status Delta avatar

Colorado DORA Professional License Status Delta

Under maintenance

Pricing

$10.00 / 1,000 license status changeds

Go to Apify Store
Colorado DORA Professional License Status Delta

Colorado DORA Professional License Status Delta

Under maintenance

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.

Pricing

$10.00 / 1,000 license status changeds

Rating

0.0

(0)

Developer

Radu Furtuna

Radu Furtuna

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

6 days ago

Last modified

Share

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

{
"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)