CA DRE Real Estate License Verification
Purpose
Verify a California real estate license by its 8-digit license ID against the California Department of Real Estate (DRE) Public License Lookup at https://www2.dre.ca.gov/publicasp/pplinfo.asp. Returns the licensee's name, license type (BROKER, SALESPERSON, or CORPORATION), license status (LICENSED, EXPIRED, SURRENDERED, REVOKED, CANCELED, etc.), expiration date, original issue date, mailing address, main office, DBAs, branches, affiliated corporations, and the full disciplinary-action timeline including links to any formal-action PDF documents. Read-only.
When to Use
- Verify a real estate agent / broker before signing a representation agreement.
- Compliance pipelines that need to confirm an agent's license is currently active before referrals, MLS access, or commission payouts.
- Surfacing disciplinary history (revocations, restricted licenses, petitions for reinstatement) for due-diligence on a counterparty.
- Resolving the affiliated brokerage / corporation graph (each affiliated corp link is a clickable license ID — chain-followable for org-tree mapping).
Workflow
The DRE public lookup is a 2012-era ASP application served by Microsoft-IIS/10.0. It is not JavaScript-rendered, has no anti-bot, no CAPTCHA, no auth, and no rate-limiting headers, and the form's <form method="post" action="pplinfo.asp?start=1"> server-side handler accepts the same parameters via GET when the input is a LICENSE_ID. A single HTTPS GET returns the complete result page — no session, no proxy, no browser required. Lead with fetch; the browser fallback at the end is ~30× more expensive and exists only for the name-search variant (see Site-Specific Gotchas — LICENSEE_NAME requires POST).
Recommended path — direct GET fetch
-
Construct the URL (license number zero-padded to 8 digits):
GET https://www2.dre.ca.gov/publicasp/pplinfo.asp?start=1&h_nextstep=SEARCH&LICENSE_ID={8-digit-id}Both
start=1(action route discriminator) andh_nextstep=SEARCH(hidden form field that triggers the search branch in the ASP code) are required. Omittingh_nextstep=SEARCHre-renders the empty form. The endpoint returns200 OK text/htmleither way; success vs. not-found is encoded in the response body, never via status code. -
Branch on the response body:
- Contains
>>>>> Public information request complete <<<<→ found — parse the field table. - Contains
No matching public record was found for License ID:→ not found — emit{found: false, license_id, reason: "not_found"}. - Contains
Please enter either a licensee name or a license id→ input rejected —LICENSE_IDwas empty or malformed; check that you passed exactly 8 digits.
- Contains
-
Parse the field table. On
foundresponses the page body is a flat<table>with one row per field. Each row's left<td>carries<strong>{Label}:</strong>(sometimes wrapped in<A HREF="...">for the help-link fieldsLicense Status,Comment, and the issuance label) and the right<td>carries the value inside<FONT FACE="Arial,Helvetica" size=2>...</FONT>. Robust extraction regex for any labelL:<strong>(?:<A HREF[^>]*>)?L(?:</A>)?:</strong><font><br/></td><td><FONT[^>]*>([\s\S]*?)</font></td>Then strip
<br/>(replace with\nor|), strip remaining tags, and collapse whitespace.Labels observed across all license types:
License Type—BROKER,SALESPERSON, orCORPORATION. (For a corporation the issuance label becomesCorporation License Issued; for a broker it'sBroker License Issued; for a salesperson it'sSalesperson License Issued.)Name—"Last, First Middle"for individuals, full company name for corporations.Mailing Address— multi-line,<br/>-separated. Sometimes carries the trailing line(Above address is marked unreliable in DRE database)— preserve it; it's a signal to downstream callers.License ID— the canonical zero-padded 8-digit ID.Expiration Date—MM/DD/YY(two-digit year — see gotchas).License Status—LICENSED(active and current),EXPIRED,SURRENDERED,REVOKED,CANCELED,SUSPENDED(and a few rarer states — surface the raw string).Broker License Issued/Salesperson License Issued/Corporation License Issued— original issue date, often suffixed(Unofficial -- taken from secondary records)for older licenses.Former Name(s)— usuallyNO FORMER NAMES.Main Office— may beNO CURRENT MAIN OFFICE ADDRESS ON FILE, or a multi-line address. For corporations, also contains chained anchor links to division/branch managers — each<A HREF = "pplinfo.asp?License_id=XXXXXXXX">XXXXXXXX</A>is an affiliated license ID followed byname - role.DBA—NO CURRENT DBASor a list.Branches—NO CURRENT BRANCHESor a multi-line list, again with chained anchor links to the branch manager's license ID.Affiliated Licensed Corporation(s)— similar chained-anchor list, each entry isLICENSE_ID - Officer Expiration Date: MM/DD/YY | Corporation Name.Comment— this is the disciplinary timeline. Either the single stringNO DISCIPLINARY ACTION, or one or more dated lines likeMM/DD/YY - H-NNNNN SF(case hearing number),MM/DD/YY - BROKER LICENSE REVOKED-RIGHT TO RESTRICTED SALESPERSON LICENSE,MM/DD/YY - PETITION FOR REINSTATEMENT OF BROKER LICENSE GRANTED,MM/DD/YY - H-NNNNN SF RELEASED, etc. Each line is in a separate<tr>row — the first row carries theComment:strong label, subsequent rows have empty left<td></td>and the next comment line in the right<td>. Terminate the collection at the row containingNO OTHER PUBLIC COMMENTS.Disciplinary or Formal Action Documents— appears only when disciplinary action exists. One or more<A HREF="/hearingfiles/HNNNNN_*.pdf" target="_blank">filename.pdf</A>rows. Resolve to absolute URLs by prefixing withhttps://www2.dre.ca.gov.
-
Terminate at the sentinel. The body always ends with the row
>>>>> Public information request complete <<<<— anything before it is the licensee record, anything after is</table></body></html>. Use the sentinel to validate that the response was not truncated mid-document. -
Validate against a Zod schema (illustrative; field names map 1:1 to the labels above):
import { z } from "zod"; const LicenseStatus = z.enum([ "LICENSED", "EXPIRED", "SURRENDERED", "REVOKED", "CANCELED", "SUSPENDED" ]).or(z.string()); // fall back to raw string for unknown states const LicenseRecord = z.object({ license_id: z.string().regex(/^\d{8}$/), license_type: z.enum(["BROKER", "SALESPERSON", "CORPORATION"]), name: z.string(), mailing_address: z.string(), mailing_address_unreliable: z.boolean(), // true if "(Above address is marked unreliable...)" is present expiration_date: z.string(), // MM/DD/YY as given; do NOT auto-parse to ISO without 4-digit-year heuristic status: LicenseStatus, license_issued: z.string(), // MM/DD/YY, may have "(Unofficial ...)" suffix license_issued_unofficial: z.boolean(), former_names: z.array(z.string()), // [] when "NO FORMER NAMES" main_office: z.string().nullable(), // null when "NO CURRENT MAIN OFFICE ADDRESS ON FILE" dba: z.array(z.string()), branches: z.array(z.object({ // anchor-link entries; empty array when "NO CURRENT BRANCHES" license_id: z.string(), text: z.string(), })), affiliated_corporations: z.array(z.object({ license_id: z.string(), officer_expiration_date: z.string().nullable(), name: z.string(), })), disciplinary_actions: z.array(z.string()), // [] when "NO DISCIPLINARY ACTION" disciplinary_documents: z.array(z.object({ filename: z.string(), url: z.string().url(), // absolute https://www2.dre.ca.gov/hearingfiles/... })), retrieved_at: z.string(), // "License information taken from records of the Department of Real Estate on M/D/YYYY H:MM:SS AM/PM" — parsed off the page header }); const Result = z.discriminatedUnion("found", [ z.object({ found: z.literal(true), license: LicenseRecord }), z.object({ found: z.literal(false), license_id: z.string(), reason: z.literal("not_found") }), z.object({ found: z.literal(false), reason: z.literal("invalid_input"), message: z.string() }), ]);
Browser fallback
Required only when the lookup is by licensee name rather than license ID (the ASP handler reads LICENSEE_NAME from Request.Form, not Request.QueryString, so a name-search GET silently returns "Please enter either a licensee name or a license id"). For license-ID lookups the GET path above is strictly superior — use it.
sid=$(browse cloud sessions create --keep-alive | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const j=JSON.parse(s.replace(/.*?\n\{/,'{'));process.stdout.write(j.id)})")browse open "https://www2.dre.ca.gov/publicasp/pplinfo.asp" --remote --session "$sid"browse fill 'input[name=LICENSEE_NAME]' "Last, First" --remote --session "$sid"browse fill 'input[name=CITY_STATE]' "{optional city}" --remote --session "$sid"browse click 'input[type=submit][value=Find]' --remote --session "$sid"browse wait load --remote --session "$sid"- If multiple matches → site renders an interstitial result list (each row links to
pplinfo.asp?License_id=NNNNNNNN); harvest the anchors withbrowse get html body, present to the caller for disambiguation, then GET the chosen ID via the recommended path. - If single match → site renders the same field table as the GET path; parse identically.
browse cloud sessions update "$sid" --status REQUEST_RELEASE
Bare session (no --verified, no --proxies) is sufficient — this is a 2012-era CA.gov ASP site with no anti-bot.
Site-Specific Gotchas
LICENSE_IDaccepts GET;LICENSEE_NAMEdoes NOT. The ASP handler readsLICENSE_IDfromRequest.QueryStringbutLICENSEE_NAMEonly fromRequest.Form. A GET withLICENSEE_NAME=...silently behaves as if no name was submitted and prints "Please enter either a licensee name or a license id." Confirmed by direct fetch comparison. License-ID is the canonical fast path; for name searches use the browser POST (form submit) fallback.- Both
start=1andh_nextstep=SEARCHquery params are required. Omitting either re-renders the empty form. Other hidden routes (start=2, alternateh_nextstepvalues) are unused server-side. - The response is always
200 OK— including not-found, including malformed input. Branch on the body text (>>>>> Public information request complete <<<<for found,No matching public record was foundfor not-found,Please enter either a licensee name or a license idfor input rejection), never on HTTP status. LICENSE_IDmust be exactly 8 digits, zero-padded. Older licenses are00XXXXXX. The input hasmaxlength="8". A 7-digit or unpadded ID is silently treated as malformed.- Expiration / issuance dates are
MM/DD/YYwith a two-digit year. Do not naïvely prepend20—00696976's issue date02/07/79is 1979, not 2079. Use a sliding-window heuristic (e.g., ifYY >= current_year + 5then19YY, else20YY) and surface the raw two-digit string alongside any parsed ISO date. (Unofficial -- taken from secondary records)suffix on issuance dates is present for licenses originally issued before DRE's modern recordkeeping (~mid-2000s cutoff). Preserve it as a boolean flag — it's a confidence signal callers care about.(Above address is marked unreliable in DRE database)suffix on mailing addresses is a DRE-flagged signal that the address is stale or undeliverable. Preserve as a boolean flag.- The
Commentsection is multi-row, not multi-line. When a license has disciplinary history, every dated event is in its own<tr>with an empty left<td></td>and the event text in the right<td><FONT...>...</FONT></td>. The first row of the section carries theComment:label; collection terminates at the row containingNO OTHER PUBLIC COMMENTS. A naïve "match the value afterComment:" parser captures only the first event line and misses the full timeline. - The
Disciplinary or Formal Action Documentssection only appears when there is disciplinary history. Same multi-row pattern asComment. Hrefs are root-relative (/hearingfiles/HNNNNN_P.pdf); prefix withhttps://www2.dre.ca.govto absolutize. - For
CORPORATIONrecords,Main Officecarries an embedded chain of officer license links (e.g.,LICENSE_ID - Name - Division Manager, repeated). Each anchor's text is the 8-digit license ID — chain-followable to map the org tree. Same forBranches. - License status enum is open-ended. Confirmed values from probes:
LICENSED,EXPIRED. Documented elsewhere on DRE static pages:SURRENDERED,REVOKED,CANCELED,SUSPENDED, restricted variants. The Zod schema should accept the known enum or fall back to the raw string so an unknown status doesn't break validation. - There is no JSON / GraphQL / structured API endpoint.
secure.dre.ca.gov/addresslookupis a sibling address-based search (also browser-only);pplinfo2.dre.ca.govis referenced in a commented-out<meta refresh>tag in the source but currently inactive (don't rely on it). The HTML scrape is the public contract. - No rate limiting observed.
Microsoft-IIS/10.0, noRetry-After, noX-RateLimit-*headers. Be courteous (≤ 2 req/s sustained) and the site is happy. - A residential proxy and stealth mode are NOT required. Bare
browse cloud fetch(or even a plain Nodefetch) works end-to-end. Don't waste a--proxiesallocation on this site. - Co-browsing script (Median) and Google Translate widget load on every response — they're cosmetic and don't affect the field table. Ignore.
Expected Output
Three distinct outcome shapes:
// 1. Found — individual licensee (BROKER, with no disciplinary history)
{
"found": true,
"license": {
"license_id": "01365477",
"license_type": "BROKER",
"name": "Riley, Joshua Luke",
"mailing_address": "20406 BRIAN WAY\nSUITE 3A\nTEHACHAPI, CA 93561",
"mailing_address_unreliable": false,
"expiration_date": "09/26/29",
"status": "LICENSED",
"license_issued": "01/31/03",
"license_issued_unofficial": false,
"former_names": [],
"main_office": "20406 BRIAN WAY\nSUITE 3A\nTEHACHAPI, CA 93561",
"dba": [],
"branches": [],
"affiliated_corporations": [
{
"license_id": "01910265",
"officer_expiration_date": "02/08/28",
"name": "Associated Real Estate"
}
],
"disciplinary_actions": [],
"disciplinary_documents": [],
"retrieved_at": "5/21/2026 11:46:44 AM"
}
}
// 2. Found — corporation with chained officers / branches
{
"found": true,
"license": {
"license_id": "01527235",
"license_type": "CORPORATION",
"name": "Compass California II, Inc.",
"mailing_address": "9454 WILSHIRE BLVD STE 100\nBEVERLY HILLS, CA 90212",
"expiration_date": "08/15/28",
"status": "LICENSED",
"license_issued": "08/16/16",
"main_office_officers": [
{ "license_id": "02106770", "name": "Finlay, Emma Kathleen", "role": "Division Manager" },
{ "license_id": "01392561", "name": "Patsel, Kevin Blaine", "role": "Division Manager" },
{ "license_id": "01868574", "name": "Brand, Kristian Frederik", "role": "Division Manager" }
],
"branches": [
{ "license_id": "01421420", "name": "Supica, Stacey", "role": "Branch/Division Manager",
"address": "0 JUNIPERO BETWEEN 5TH & 6TH\nCARMEL BY THE SEA, CA 93921" }
],
"disciplinary_actions": [],
"disciplinary_documents": []
}
}
// 3. Found — with disciplinary action timeline + PDF documents
{
"found": true,
"license": {
"license_id": "01000200",
"license_type": "BROKER",
"name": "...",
"status": "LICENSED",
"disciplinary_actions": [
"10/24/09 - H-10787 SF",
"09/14/10 - BROKER LICENSE REVOKED-RIGHT TO RESTRICTED SALESPERSON LICENSE",
"12/22/14 - (H-10787 SF) PETITION FOR REINSTATEMENT OF BROKER LICENSE GRANTED",
"06/09/16 - H-10787 SF RELEASED"
],
"disciplinary_documents": [
{ "filename": "H10787SF_141222_P.pdf", "url": "https://www2.dre.ca.gov/hearingfiles/H10787SF_141222_P.pdf" },
{ "filename": "H10787SF_P.pdf", "url": "https://www2.dre.ca.gov/hearingfiles/H10787SF_P.pdf" }
]
}
}
// 4. Not found — license ID does not match any DRE record
{ "found": false, "license_id": "00000000", "reason": "not_found" }
// 5. Invalid input — empty or malformed license ID
{ "found": false, "reason": "invalid_input", "message": "Please enter either a licensee name or a license id." }