# Delaware Professional License Disciplinary Actions Delta (`titan_coder/de-professional-license-disciplinary-delta`) Actor

Tracks Delaware's official professional license disciplinary action registry (nursing, real estate, electricians, medicine, cosmetology and more) and alerts only on genuinely new or revised revocations, suspensions, probations, and fines. Built for employers, HR, insurers, and compliance teams.

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

## Pricing

$10.00 / 1,000 new disciplinary action detecteds

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Delaware Professional License Disciplinary Actions Delta

Durable monitor for Delaware's public **Disciplinary Actions for Professional and Occupational Licensees**
registry — one dataset covering ALL 37 professions the Delaware Division of Professional Regulation (DPR)
regulates (nursing, real estate, electricians, medicine, cosmetology, plumbing/HVAC, pharmacy, and more).
Watch by profession, license number, and/or name, and get notified only about disciplinary actions
genuinely new or revised since your last check. No API key needed, no login, no captcha.

### Source

`https://data.delaware.gov/resource/dz6p-akeq.json` — Socrata SODA JSON API for dataset `dz6p-akeq`
("Disciplinary Actions for Professional and Occupational Licensees"), confirmed live 13.09.2026: 8,475 rows,
updated regularly (22 new rows since 01.08.2026 at verification time, latest action dated 2026-09-03).

**This is an append-only registry of discrete disciplinary actions, not a point-in-time license status
lookup.** One license/person can have several rows (up to 16 for a single license as of 13.09.2026) — each
row is one action (Probation, Revocation, Fine, Letter of Reprimand, Suspension, etc., in `item_text`).
There is no field for "this license's current status" by itself, only the list of actions ever taken
against it. This actor therefore watches for **new or revised rows appearing in the registry**, the same
architecture as `ncua-credit-union-enforcement-watch` — not for a status field flipping value, the
architecture used by `tx-nursing-board-license-status-delta` and similar point-query monitors. See
ROADMAP.md for the full reasoning behind this choice.

### How it works

1. Each `watch` is an optional `professionId` (exact match, e.g. `"Nursing"`, `"Electrical Examiners"`,
   `"Real Estate"`, `"Plumbing/HVACR"` — see the full list below), `licenseIdContains` (case-insensitive
   substring against the license number, e.g. `"L1-0043682"`), and/or `nameContains` (case-insensitive
   substring against the licensee's name or business name) — or none of the three, to watch the entire
   statewide registry.
2. Every run downloads the entire registry in a single HTTP request (cached for the whole run regardless
   of how many watches you define) and filters it per watch on our side — the API has no server-side
   filter scoped to a single watch, but the whole registry is only ~8,500 rows and growing slowly, and one
   fetch serves every watch in the run. A single request over the whole snapshot — rather than paging
   through it — also means the read is consistent: the source database's own snapshot isolation guarantees
   we see one coherent point-in-time state, not a mix of before/after a mutation that happened to land
   between two page requests.
3. A stable identity is kept per row using Socrata's own row identifier (`:id`), plus a content hash of
   the substantive fields (license number, profession, license type, action text, start/end dates, name).
   If Delaware ever edits an existing row in place, the content hash changes and it is honestly treated as
   a revision, never silently ignored or double-billed for no reason; if the exact same row reappears
   unchanged, nothing is re-billed.
4. Genuinely new or revised rows are pushed to the dataset and billed once each
   (`new-disciplinary-action-detected`); a check that finds nothing new costs nothing beyond the fixed
   platform run cost.

### Input

```json
{
  "monitorId": "my-de-license-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    { "watchId": "electricians", "professionId": "Electrical Examiners" },
    { "watchId": "one-license", "licenseIdContains": "L1-0043682" }
  ],
  "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 filter it first saw; pointing the same `watchId` at a different
`professionId`/`licenseIdContains`/`nameContains` later fails the run instead of silently mixing histories.

#### All 37 regulated professions (`profession_id`, verified live 13.09.2026)

Accountancy, Adult Entertainment, Architecture, Charitable Gaming, Chiropractic, Combative Sports,
Controlled Substances, Cosmetology and Barbering, Deadly Weapons Dealers, Dentistry,
Dietitians/Nutritionists, **Electrical Examiners**, Elevator Mechanics, Funeral Services, Geologists, Home
Inspectors, Landscape Architecture, Land Surveyors, Manufactured Home Installation, Massage Bodywork,
Medical Practice, Mental Health, Nursing, Nursing Home Administrators, Occupational Therapy, Optometry,
Pharmacy, Physical Therapy/Athletic Trg, Pilots, **Plumbing/HVACR**, Podiatry, Psychology, Real Estate,
Real Estate Appraisers, Social Work Examiners, Speech and Hearing, Veterinary Medicine.

### Output row (per action)

`watchId, stableRowId, licenseId, professionId, licenseType, subjectType ("individual"|"business"),
lastName, firstName, combinedName, itemText, dispStart, dispEnd, monitorId, runId, discoveredAt, eventId,
billed`

`subjectType` is `"business"` (e.g. a cosmetology salon, an elevator-mechanic company) when the row has no
individual name — only 98 of 8,475 rows as of 13.09.2026, all of Delaware's other 8,377 rows name a person.

### Billing

Pay-per-event: `new-disciplinary-action-detected` — charged only for a row genuinely new or revised 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 decisions

**This actor is an informational monitor of NEW or REVISED publications to Delaware's official
disciplinary-action registry — it is NOT a current license-status check, NOT a background-check report,
and NOT legal advice.** The registry itself does not state whether an action has since been resolved,
appealed, or superseded beyond what `dispEnd` (often absent — see Honest limits) records; names may not
reflect a person's current legal name. Always confirm directly with Delaware DPR (`dpr.delaware.gov`) or
counsel before acting on any single entry, especially for employment or licensing 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 — the next run moves on to the watch's next disciplinary action. 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.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.** Each
  row is written to the durable dataset first, then mirrored to the run's default dataset before billing
  proceeds for that row. If the durable write succeeds but the default-dataset mirror write fails (e.g. a
  transient Apify storage error), the item is marked `dataset_unknown`, billing for it is permanently
  blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the run is not
  retried into re-creating that exact row. The durable dataset can therefore end up with a small number of
  orphan rows that were never mirrored and never billed. The **default dataset is the canonical log of
  rows successfully written to this run's output** (see its `run_summary` row) — but a default-dataset row
  does not by itself prove the row was billed: the row is written before `Actor.charge()` runs, so if
  charging then fails or comes back `charge_unknown`, the row is present but not confirmably paid.
  **`run_summary.eventsBilled` and Apify's own billing ledger are the source of truth for confirmed
  payment**, not the presence of a row in either dataset.
- **Single-request snapshot integrity guard.** The whole registry is fetched in one HTTP request rather
  than paginated with `$offset`/`$limit` — verified live that the source's `$limit` is effectively
  unbounded for this registry's size (a `$limit` of 999,999 and even 50,000,000 both returned all 8,475
  rows in a single ~1.5-1.8s/2.46 MB request). This closes an entire class of integrity risk that page-based
  fetching cannot: a page-by-page read only has an integrity boundary *between* pages, so a row inserted or
  deleted strictly *inside* an already-read page (not touching the page boundary or the final row count)
  would go undetected. A single request has no "inside a page" for a mutation to hide in — the source
  database's own snapshot isolation guarantees the whole response reflects one coherent point in time.
  What remains, and is still checked (see `de_client.py::_ensure_docs`): every row's `:id` is tracked
  across the single response — any duplicate raises `structure_changed` for the whole run instead of
  silently collapsing into one entry; and a separate `$select=count(*)` request, made immediately before
  the snapshot request, must match the number of rows the snapshot actually returned exactly (in either
  direction) — a mismatch means the registry changed in the narrow window between the two requests, and
  the run honestly reports `structure_changed` rather than build on an unverifiable snapshot. A defensive
  ceiling (`SODA_MAX_LIMIT`, currently 100,000 — about 12x today's live row count) also guards against
  silent truncation if the registry ever grows past what a single request can safely retrieve: if the
  independent `count(*)` already exceeds that ceiling, the run fails closed before even requesting the
  snapshot, rather than silently accepting a truncated `$limit`-capped response as if it were complete.
- **No server-side filter exists for a single watch** (verified live) — `professionId`/`licenseIdContains`/
  `nameContains` are always applied client-side over one shared snapshot per run; they never reduce the
  actual network cost (the whole registry is fetched regardless), only the rows delivered to you.
- **Row identity relies on Socrata's `:id` field being stable across time for the same underlying record**
  — verified live that it is NOT a pure content hash (5 rows with fully identical visible field content
  received 5 distinct `:id` values, ruling out hash-collapse of duplicates), consistent with a persistent
  internal surrogate key, but this could not be proven across repeated snapshots over time within a single
  verification session. As a defensive measure (not a documented Socrata guarantee), we still track a
  content hash and a change-sequence counter per `:id` — if Delaware ever edits an existing row in place,
  it is honestly reported as a revision rather than silently lost or double-charged.
- Each watch's window is capped at `MAX_ROWS_PER_WATCH` (10,000) matching rows, newest-first by `dispStart`
  (rows with no `dispStart` — 121 of 8,475 live — sort last). `coverage.windowFullCount` and each watch's
  row in `coverage.watches` (`windowFull`/`matchedCount`) flag when a watch's real match count exceeded
  that window — informational, not silently dropped data. `coverage.sourceAccessLimitedCount` is a
  separate, unrelated counter: it only counts watches where the registry snapshot itself could not be
  trusted this run (missing/empty required fields — see below), never a watch that simply had more matches
  than the window.
- Row-level identity and integrity depend on the registry always supplying a non-empty `:id`,
  `license_id_l`, `profession_id`, and `license_type` for every row — all four verified true for all 8,475
  rows live on 13.09.2026 (0 nulls/empties). If any row is missing one of these (or a nullable field like
  `dispEnd`/`itemText`/name fields is present but contains only whitespace — not a legitimate empty value),
  the whole run for that check is reported honestly as `source_access_limited` — no partial
  baseline/delta/billing happens on a snapshot whose completeness can't be trusted.
- `dispEnd` is legitimately absent for the majority of rows (5,608 of 8,475 live — most actions have no
  recorded end date, e.g. permanent or still-open). `dispStart` (121/8,475), `itemText` (12/8,475), name
  fields (98/8,475, all business-only rows), and `combinedName` (1,429/8,475) are also legitimately
  nullable. None of these participate in the `source_access_limited` integrity check above.
- **Two subject types, not one convention.** `subjectType` is `"individual"` only when both `lastName` and
  `firstName` are present; otherwise `"business"` (only `combinedName`, e.g. a salon or company). Verified
  live: on every one of the 98 rows without a personal name, both name fields are absent together — no
  case of only one being empty was found, so no third `subjectType` is defined.
- History is kept per row (not a capped seen-ids list): each row's own state (its latest content hash and
  a change-sequence counter) is stored indefinitely per watch, so a row that changes and later reverts to
  an earlier state is billed correctly on every genuine transition, never silently deduplicated against an
  older occurrence of the same content.
- We don't invent data: if the API's response shape changes, or the registry grows past the defensive
  `SODA_MAX_LIMIT` ceiling for a single request, the run reports it honestly (`structure_changed: ...`)
  instead of silently returning zero results or a truncated 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.delaware.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-20 objects: {"watchId": "my-watch", "professionId": "Nursing" (optional, exact match — e.g. Electrical Examiners, Real Estate, Plumbing/HVACR, Medical Practice, Cosmetology and Barbering; see README for the full list of 37), "licenseIdContains": "..." (optional, case-insensitive substring match against the license number, e.g. "L1-0043682"), "nameContains": "..." (optional, case-insensitive substring match against the licensee's name or business name)}. Leave all three filters empty to watch the entire statewide registry (all professions). New watches can be added later under the same monitorId.

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

new\_alerts — post the webhook only when new/revised paid disciplinary actions were delivered; always — post it every run; never — do not call webhookUrl at all.

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

Optional. Receives a digest of delivered (paid) new/revised disciplinary actions as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-de-license-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "electricians",
      "professionId": "Electrical Examiners"
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, licenseId, professionId, licenseType, subjectType, lastName/firstName/combinedName, itemText, dispStart, dispEnd.

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

What this run actually covered and what it charged for: per-watch status and reason, actions delivered and billed, and whether the registry snapshot had to be truncated for any watch. 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-de-license-watch",
    "userAgentContact": "you@example.com",
    "watches": [
        {
            "watchId": "electricians",
            "professionId": "Electrical Examiners"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/de-professional-license-disciplinary-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-de-license-watch",
    "userAgentContact": "you@example.com",
    "watches": [{
            "watchId": "electricians",
            "professionId": "Electrical Examiners",
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/de-professional-license-disciplinary-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-de-license-watch",
  "userAgentContact": "you@example.com",
  "watches": [
    {
      "watchId": "electricians",
      "professionId": "Electrical Examiners"
    }
  ]
}' |
apify call titan_coder/de-professional-license-disciplinary-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/de-professional-license-disciplinary-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/F5jFaY3mDNxvefThb/builds/Po3M2opxE7t4x5iIC/openapi.json
