# CA DRE Real Estate License Status Delta (`titan_coder/ca-dre-real-estate-license-status-delta`) Actor

Watches named California DRE real estate license holders and alerts on a genuine status change, a restricted-license flag, or the license disappearing from the Licensed/Licensed NBA registry entirely (expiration, surrender, or revocation). For HR screening, brokerages, insurers, due-diligence.

- **URL**: https://apify.com/titan\_coder/ca-dre-real-estate-license-status-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 75.0% 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

## CA DRE Real Estate License Status Delta

Watches specific California real estate license holders in the official California Department of Real
Estate (DRE) daily bulk registry (`secure.dre.ca.gov/datafile/CurrList.zip`) and delivers/bills only on a
genuine change since your last check — never a re-delivery of an unchanged status.

**Not a lookup tool and not legal advice.** This is an informational monitor of a public government
registry's publication state. It is not a legal determination of anyone's current right to practice real
estate in California, not a full disciplinary history, and not a substitute for the official lookup at
`secure.dre.ca.gov/publicasp/pplinfo.asp` before any decision that matters (hiring, a brokerage
relationship, a commission deal, E\&O insurance, due diligence).

### What it watches

One watch = one DRE license number (`lic_number`, 1-8 digits — leading zeros optional, e.g. `7012` or
`00007012`). Per run it checks each watch against the **same day's snapshot** of the official bulk file and
reports:

- **`new`** — first time this license number was found (after baseline; the very first sighting of a watch
  is a free baseline, not billed).
- **`status_changed`** — `lic_status` (`Licensed` / `Licensed NBA`) or the `restricted_flag` changed while
  the license stayed in the registry.
- **`removed_from_registry`** — the license number, previously found, is now **absent from the bulk file
  entirely** (no row at all, under any role) on **two consecutive** daily runs. See "Honest limits" below —
  this is the disciplinary/lifecycle signal for this source, and it is a two-strike confirmation (see next
  section), not a single-run signal.
- **`restored`** — a license previously *confirmed* `removed_from_registry` is found again (reissued/
  reinstated).
- **`candidate_removed`** — a license, previously found, is absent on this run **for the first time**. This
  is informational only: it is delivered with `billed: false` and never charges the
  `license-status-changed` event. If the license is found again on a later run before a second consecutive
  absence, the candidate is silently cancelled — nothing is billed either way.

Every genuine transition (`new`, `status_changed`, `restored`, and a *confirmed* `removed_from_registry`) is
billed once under the same event; the same unchanged status is never billed twice, and `candidate_removed`
is never billed.

### Two-strike removal confirmation

A single run's snapshot integrity gate (see below) only proves the file isn't catastrophically truncated —
it accepts a snapshot that is genuinely valid but 1-9% smaller than the last one (a normal, honest day of
registry churn). At 429,000+ rows, that "acceptable" shrinkage window is on the order of tens of thousands of
license numbers, so a single run's disappearance is not, by itself, strong enough evidence that DRE
actually removed the license — the row could simply have landed in that legitimate day-to-day slack.

To avoid billing `removed_from_registry` on a false signal, this actor requires the disappearance to repeat
on the **next** run before charging anyone:

1. **First consecutive absence** of a previously-found, not-yet-removed license → recorded internally as a
   pending candidate and delivered as `candidate_removed`, `billed: false`. The license's last known status
   is left untouched in the actor's history — only a candidate flag is set.
2. **If the license is found again** before a second consecutive absence, the candidate flag is simply
   cleared. Nothing is billed, nothing is reported as `removed_from_registry` or `restored` — as far as
   billing is concerned, this never happened.
3. **Second consecutive absence** (the license is still missing on the very next run that itself passes the
   integrity gate) → *now* confirmed and billed as `removed_from_registry`, and the license is marked
   removed in history. A `restored` event (billable) can only happen after this confirmed state.

In short: the first disappearance is a free, unbilled warning; billing only happens once the same
disappearance has been independently observed twice in a row.

### Delivery/billing guarantee: **at-most-once**, not exactly-once

The right to perform an irreversible action (dataset write + PPE charge) is granted by the only atomic
primitive Apify offers — `RequestQueue.addRequest(uniqueKey)` → `wasAlreadyPresent` — in a separate
named queue that acts as a permanent journal (`<prefix>-<monitorId>-claims`). The Apify Key-Value Store
has no CAS, no conditional write and no ETag, so it can only ever be a diagnostic state machine, never
the source of at-most-once.

Concretely: for one computed event, delivery and charging each happen **no more than once**. If the run
dies *after* taking the claim, the event may be **lost** (it stays `dataset_unknown`/`charge_unknown`
and is never re-delivered) — but you will never be billed twice. That is a deliberate trade: "never
overcharge" beats "never lose a row".

Boundaries of the guarantee, stated honestly:

- Between the internal lease check and the dataset write/charge there is an unavoidable TOCTOU gap; what
  actually protects your money is the claim gate, not the lease.
- The guarantee holds for as long as the named claims queue exists. Anyone with account access can
  delete or recreate it via Console/API, which starts the journal from zero. This is a boundary of any
  durable storage, not a defect of the protocol.
- The guarantee applies from the build in which the claim gate was introduced onward. Older builds must
  not keep running against the same `monitorId`.

The free `candidate_removed` row is covered by the same rule: it costs nothing, but a dataset write is
irreversible, so it passes through its own claim gate (a separate key namespace whose key contains no
`runId` — it is keyed by the license number, its change sequence and the *missing epoch*, so a licence
that disappears, comes back unchanged and disappears again gets a second, distinct right). Consequently
that informational row is at-most-once as well: it may be lost, never duplicated. The two-strike
confirmation itself is unaffected — it is driven by the durable index, not by whether the row was
delivered.

### Honest limits — read this before relying on `removed_from_registry`

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

California DRE's bulk file is **not** a historical record — it is a working list. Confirmed directly from
DRE's own published record layout (Form RE 776, "Record Format — Licensee & Examinee data files"):

> "This list includes only DRE Licensee's who have a DRE license status of 'Licensed' or 'Licensed NBA'."

That means a license that leaves `Licensed`/`Licensed NBA` status (revoked, suspended, surrendered, or
simply expired without renewal) is not marked with a different status value the way Texas TREC does
(`status='Deleted'` stays as an explicit value on the row) — it **disappears from the file as a row,
entirely**. This actor treats that disappearance as the honest billable signal `removed_from_registry`,
distinct from a real fetch failure (a corrupted/empty ZIP, a changed CSV header, or a violated uniqueness
contract — those remain a fail-closed `source_access_limited`, with no baseline/history/billing change).

**`removed_from_registry` is only ever emitted after the run's snapshot has passed a fail-closed integrity
gate — never from any disappearance in a potentially truncated file.** Before a single watch is classified as
new/changed/removed, every run must validate the *entire* downloaded snapshot: the ZIP passes
`testzip()` (no CRC corruption), it contains **exactly one** file named `CurrList.csv` (not zero, not more,
not a different name), its decompressed size is under a sane cap, its bytes decode as **strict UTF-8 (no
silent replacement of invalid bytes)**, and the number of valid parsed rows is at least 300,000 **and** not a
material drop (more than ~10%) from the row count of the last snapshot that itself passed this gate (persisted
durably per monitor). If any of that fails, the run ends `source_access_limited` for **every** watch in the
run — no baseline is read or written, no `removed_from_registry`/`restored`/`status_changed` event fires, and
no charge happens for anyone. This exists specifically to close a gap where a syntactically valid but
truncated ZIP (a cut-off download, a proxy returning a partial 200 response) would otherwise have looked like
a legitimate, fully-updated snapshot and turned every previously-tracked license absent from the truncated
file into a billable `removed_from_registry`. It catches *catastrophic* truncation, but a snapshot that
legitimately shrinks by up to ~9% still passes — see "Two-strike removal confirmation" above for how billing
still avoids acting on that gap from a single run.

**What we verified live vs. what we did not, and why this matters:** on 2026-09-13 we downloaded the live
file (429,248 data rows), parsed its full structure, cross-checked several `restricted_flag=Y` records, and
independently confirmed the quoted sentence above by downloading and parsing DRE's own RE 776 format
document. What we did **not** do — because the source updates once a day and the build window was a few
hours, not a day-plus — is watch one specific, real license actually leave the file between two runs and
confirm no other explanation fits. The "disappearance = left Licensed/Licensed NBA (commonly expiration,
surrender, or revocation)" conclusion rests on DRE's own documented file contract, not on an observed live
transition of a real record. If a future run of this actor (or an audit) ever finds a disappearance that is
better explained by something else, that is a real bug report, not an expected edge case.

**A `removed_from_registry` event never tells you *why*.** It does not distinguish "license expired and
was not renewed" from "license was revoked for cause" — DRE's bulk file does not carry that distinction at
all. For anything consequential, follow up at `secure.dre.ca.gov/publicasp/pplinfo.asp` (which does show a
license's current standing and, separately, DRE's public disciplinary actions page) before acting.

**License numbers are not always unique rows.** A person can appear more than once in the same file — once
under their own license (Broker/Salesperson/Corporation) and again for each corporation where they are a
registered `Officer`. Confirmed live against the full file (2026-09-13): of 406,503 distinct license
numbers, 405,051 (99.6%) have exactly one "own" row; 1,452 (0.4%) have none of their own (their personal
license is not currently in the list, but they remain an Officer of an active corporation — the officer row
with the lowest affiliated corporation number is used); zero have more than one "own" row. If that last
count is ever non-zero in a future snapshot, the actor treats it as a broken source contract
(`source_access_limited`) rather than guessing which row is "the" license.

**Restricted ≠ revoked.** `restricted_flag=Y` (2,282 of 429,248 rows on 2026-09-13) means DRE's own
documented meaning, "License is restricted" — commonly a condition/probation-style restriction. It is
tracked and billed as a status-relevant change, but it is not itself a revocation, and `Licensed NBA` is not
a disciplinary status either — DRE documents it as "the license is in a non-working status" (no broker
affiliation for a salesperson, or no business address on file for a broker/corporation), an administrative
state, not misconduct.

### Fast health check / probeOnly mode

- **`probeOnly` (boolean, default: `false`, prefill: `true`):** When set to `true`, the actor only checks network connectivity and HTTP availability of the official CA DRE source (`https://secure.dre.ca.gov/datafile/CurrList.zip`) via a lightweight HTTP HEAD / Range request. It verifies the source is online, writes a health check receipt to the dataset, and exits in seconds (under 5 seconds) with exit code 0 without downloading the full 70MB dataset or billing any events. Recommended for health checks, uptime monitors, and platform QA tests.

### Coverage and billing record

Every run writes a `coverage` record with per-watch `status`/`reason`/`matched`, `removedFromRegistryCount`
(confirmed, billed second-strike removals only), `pendingRemovalCandidateCount` (unbilled first-strike
candidates from this run), and `recordsDelivered`/`recordsBilled` — enough to reconcile every charge against
every row. A watch that never matched (typo, or a license number DRE has never issued) is reported as
`matched: false`, `status: "ok"` — not an error.

### What it does not do

- Does not search by name — you provide the license number.
- Does not scrape any DRE web page; it uses DRE's own official daily bulk export.
- Does not retain or re-deliver a status that has not changed.
- Does not cover license types outside the DRE registry (brokers, salespersons, corporations, and their
  officer affiliations) — no mortgage loan originator (NMLS) or broker-associate data.

# Actor input Schema

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

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

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

1-30 objects: {"watchId": "new-hire-smith", "licenseNumber": "7012"}. licenseNumber is the DRE license number as published in the registry (1-8 digits; leading zeros optional, e.g. 7012 or 00007012) — look it up at secure.dre.ca.gov/publicasp/pplinfo.asp if you only have a name. One watch = one license holder (person or corporation). New licenses can be added later under the same monitorId.

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

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

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

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

## `probeOnly` (type: `boolean`):

If true, only tests reachability and HTTP status of the CA DRE registry source (HEAD/Range request) without downloading the 70MB dataset. Exits in seconds. Used for health checks and automated quality monitors.

## Actor input object example

```json
{
  "monitorId": "my-dre-watch",
  "watches": [
    {
      "watchId": "example-broker",
      "licenseNumber": "7012"
    }
  ],
  "notifyOn": "new_alerts",
  "probeOnly": true
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, licNumber, changeType (new|status\_changed|removed\_from\_registry|restored), status, restrictedFlag, previousStatus, licType, lastnamePrimary, firstnameSecondary, countyName. changeType=removed\_from\_registry means the license disappeared from the DRE Licensed/Licensed NBA bulk file entirely (typically expiration, surrender, or revocation) — see README for the honest disclaimer on this signal. Informational only — not legal advice or a current-standing certification.

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

What this run actually covered and what it charged for: per-watch status/reason/matched, records delivered and billed, removedFromRegistryCount, requested/attempted/succeeded/failed watch counts. 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-dre-watch",
    "watches": [
        {
            "watchId": "example-broker",
            "licenseNumber": "7012"
        }
    ],
    "notifyOn": "new_alerts",
    "probeOnly": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/ca-dre-real-estate-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-dre-watch",
    "watches": [{
            "watchId": "example-broker",
            "licenseNumber": "7012",
        }],
    "notifyOn": "new_alerts",
    "probeOnly": True,
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/ca-dre-real-estate-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-dre-watch",
  "watches": [
    {
      "watchId": "example-broker",
      "licenseNumber": "7012"
    }
  ],
  "notifyOn": "new_alerts",
  "probeOnly": true
}' |
apify call titan_coder/ca-dre-real-estate-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/ca-dre-real-estate-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/T0cBwmGpPc9ITlRT3/builds/SMG1qVZFIU7xy02wH/openapi.json
