API docs
The licenscheck API
Contractor license records as reported by the state licensing boards, with each contractor's licenses linked across states. One base URL, JSON everywhere, no SDK needed.
https://api.licenscheck.com
Using Postman or generating a client? Download the OpenAPI spec, which describes every endpoint and field on this page. New here? Watch the 60-second demo.
Authentication
Search (10 a day, or 50 with a free key) and license records need no key. The verdict, linked licenses and monitoring need a key on a paid plan (Starter or API), sent as a Bearer token:
Authorization: Bearer lc_live_…
Keys are shown once when issued, and we store only a hash of each, so keep yours somewhere safe. Get a free key by email (search only, 50 searches a day), or a paid plan on the pricing page. A free key gets a 403 from the verdict, linked licenses and monitoring; the sample report shows what they return. To see the full verdict while browsing licenscheck.com, choose Have a key? Use it here on any locked section: the key is checked, then saved in that browser only (Sign out in the header forgets it), and each license you open counts as a verification, as it would through the API. A wrong or revoked key is refused with a 401 on every endpoint, even the free ones, so you notice a problem right away.
Rate limits
| Caller | Limit |
|---|---|
| API plan key | 300 requests a minute and 50,000 a day (UTC), per key. No daily search limit. |
| Starter plan key | 60 requests a minute and 5,000 a day (UTC), per key. No daily search limit. |
| Free key | 50 searches a day (UTC), per key. |
| Without a key | 10 searches a day (UTC), per visitor. |
A search is one GET /licenses request that returns licenses, so each page of results counts; a search that finds nothing doesn't, and neither does opening a license record. Without a paid key, every search response carries X-Searches-Remaining: the searches left today.
Over a limit, the API answers 429 with a Retry-After header saying how many seconds to wait (until midnight UTC, once the day's searches are used up). Refused requests don't count against you, so a client that waits and retries gets straight back in.
Errors
Errors are JSON with a detail field saying what went wrong.
| Status | Meaning |
|---|---|
401 | The endpoint needs a key, or the key sent is wrong or revoked. |
403 | The key is a free key, which doesn't include this endpoint. |
404 | No license has that id. |
409 | A limit on what a key can hold: its plan's watched contractors, or 3 webhooks. |
422 | A parameter is invalid, e.g. a search term under 3 characters, one without 3 letters or digits in a row, or an unknown state; detail says which. |
429 | Rate limit or today's searches reached; retry after Retry-After seconds. |
What each outcome returns
What to expect for the cases you'll wire into onboarding. Statuses are the board's own; the verdict judges dates itself, so a license its board still calls active after its expiration date reads Risk.
| Case | What the license record shows | Verdict |
|---|---|---|
| Active and current | status active, expiration_date in the future (or null where the board publishes none) | Clear, unless another check warns |
| Expires within 90 days | status active, expiration_date within 90 days | Review |
| Expired | expiration_date in the past, whatever the status | Risk found |
| Suspended or revoked | status suspended or revoked | Risk found |
| Inactive, on probation, or expired with no date | status inactive, probation, or expired without a past expiration_date | Review |
| Dropped off the board's list | delisted_at set; status is the last one the board reported | Risk found |
| Fine here, suspended in another state | This record is clean; the linked license shows in linked licenses | Review (Risk found if the linked one is revoked, unless it's a related_company: a different business sharing an address or phone) |
| Board warning on an active license | board_alerts, e.g. pending_suspension or insurance_lapsed | Review |
| Active license, but the company behind it is dissolved | business_registration.standing dissolved (Colorado so far: dissolved, revoked or withdrawn with the Secretary of State) | Review |
No match
A search that finds nothing is a 200 with "results": [] and "total": 0, and doesn't use up a free search. We never return a near-miss as a match: a search finds a name only when every word you send is in it, and a batch row we can't match to exactly one contractor comes back not_found or ambiguous, not guessed. An unknown license id is a 404.
When a board is down
We don't call the boards live while you wait: each board's full list is read daily and stored, so a board's site going down never makes a request fail or time out. A record we couldn't refresh keeps its last status, and last_verified_at says when we last read it, so you can decide how old is too old. A board file that looks wrong (an error page, or far fewer licenses than the day before) is refused rather than loaded, so an outage never marks licenses as delisted, and monitoring sends no events for it.
Search licenses
GET/licensesFree · 10 a day, 50 with a free key
Find licenses by contractor name, business name (DBA) or license number. A term is required: a state only narrows a search, so the API can't list a whole state. Results are ordered by name, and an exact license number is matched directly.
| Name | Type | Description |
|---|---|---|
q | string, required | Name, DBA or license number, 3-100 characters, with at least 3 letters or digits in a row. |
state | string | A two-letter state code, e.g. CA, to search one state. |
limit | integer | Results per page, 1-100. Default 20. |
offset | integer | Results to skip, for paging. Default 0. |
Request
curl "https://api.licenscheck.com/licenses?q=southern%20folger&state=CA"
Response
{
"results": [
{
"id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"contractor_name": "SOUTHERN FOLGER CONTRACTING INC",
"business_name": null,
"license_number": "1066907",
"license_type": "specialty_contractor",
"state": "CA",
"board": "CA_CSLB",
"status": "active",
"issue_date": "2020-07-13",
"expiration_date": "2028-07-31",
"bond_number": "100504856",
"bond_amount": 25000.0,
"address": "PO BOX 58, NEWCASTLE, CA, 95658",
"phone": "9169006593",
"state_business_id": null,
"principal_name": null,
"board_alerts": [],
"entity_type": "corporation",
"board_details": [
{"key": "bond_surety", "label": "Bond issued by", "value": "AMERICAN CONTRACTORS INDEMNITY COMPANY"},
{"key": "workers_comp", "label": "Workers' comp", "value": "Insured with STARNET INSURANCE COMPANY"}
],
"last_verified_at": "2026-09-27T10:37:45.112910Z",
"delisted_at": null,
"created_at": "2026-09-15T00:00:26.940784Z"
}
],
"total": 1,
"total_is_exact": true,
"other_states": {}
}
total is capped at 10,000; total_is_exact is false when there are more.
A search narrowed with state that finds nothing fills other_states with the states where the same search does find licenses, and how many, for example {"AZ": 1}. A license number may be sent with its board's prefix (ROC 308844) or zero padding (TACLA00149415E) when you give its state.
Get a license
GET/licenses/{id}Free · no key
One license's board record: its status, dates, bond, address, phone and principal as the board reports them, plus facts only some boards publish, such as project limits and insurance.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
Request
curl https://api.licenscheck.com/licenses/3a5cdcae-1af7-5c92-89d2-46fc2e0722af
Response
{
"id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"contractor_name": "SOUTHERN FOLGER CONTRACTING INC",
"business_name": null,
"license_number": "1066907",
"license_type": "specialty_contractor",
"state": "CA",
"board": "CA_CSLB",
"status": "active",
"issue_date": "2020-07-13",
"expiration_date": "2028-07-31",
"bond_number": "100504856",
"bond_amount": 25000.0,
"address": "PO BOX 58, NEWCASTLE, CA, 95658",
"phone": "9169006593",
"state_business_id": null,
"principal_name": null,
"board_alerts": [],
"entity_type": "corporation",
"board_details": [
{"key": "bond_surety", "label": "Bond issued by", "value": "AMERICAN CONTRACTORS INDEMNITY COMPANY"},
{"key": "workers_comp", "label": "Workers' comp", "value": "Insured with STARNET INSURANCE COMPANY"}
],
"last_verified_at": "2026-09-27T10:37:45.112910Z",
"delisted_at": null,
"created_at": "2026-09-15T00:00:26.940784Z"
}
board is the licensing body that issued the license. A state can have more than one: New York licenses come from NY_DOL (the State Dept. of Labor's public-work registry and mold and elevator licenses), NYC_DOB (New York City's Dept. of Buildings) or NYC_DCWP (New York City's Dept. of Consumer and Worker Protection, for home improvement contractors).
delisted_at is set when the board stops listing a license; status is then the last one it reported. last_verified_at is when we last saw the license in the board's data.
board_alerts lists what the board reports beyond the status: the warning signs a status of "active" hides. Each has a code, the date it's on when the board gives one, and the board's own wording or case number in detail:
pending_suspension: a suspension is coming, e.g. for lapsed workers' comppending_case: an open case, citation or judgment against the licenseinsurance_lapsed: required insurance isn't in forcebond_cancelling: the surety has filed a cancellation of the license bond, taking effectonthat date. In California, most such licenses were suspended within days of the datebond_lapsed: the license bond has expired, is unpaid, or its cancellation has taken effectenforcement_action: the board reports an enforcement actionpending_class_removal: a trade classification is being removedpast_discipline: a disciplinary record is on file, though the license is in good standingdebarred: the business is barred from public work untilon, or with no end dateunpaid_wage_assessment: the business owes wages or penalties the labor department assessed
It's empty for most licenses. Every alert except past_discipline makes the verdict Review, on this license or any license linked to it; past_discipline is shown as a note and leaves the verdict as it is.
entity_type is the business's legal form as the board records it: sole_proprietor, llc, corporation, partnership, limited_partnership, llp, joint_venture, association or trust, or null when the board doesn't say.
board_details lists facts a board publishes that not every board does, in display order. Each has a stable key for code, a label for people, and a plain-text value. They never change the verdict. New keys may be added:
license_classandproject_limit: the license's size class and the largest project it allows, e.g. Virginia's "Class B: under $150,000 a project, under $1,000,000 a year", or an Arkansas commercial license's bid limitbond_surety: the company that issued the license bondliability_insurance: the liability policy's limit and carrierworkers_comp: workers' comp coverage, e.g. the insurer, or exempt with no employeesstatus_since: when the license entered its current status, e.g. the suspension datetemporary_license,renewal_in_progress: a temporary license, or a lapsed license being renewed
Classifications
GET/licenses/{id}/classificationsFree · no key
Every trade classification held under a license, primary first. Some boards list several per license.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
Request
curl https://api.licenscheck.com/licenses/3a7580f5-6ec6-569a-b302-b3b937c673fe/classifications
Response
{
"license_id": "3a7580f5-6ec6-569a-b302-b3b937c673fe",
"classifications": [
{
"id": "4b335aed-69c6-5642-901b-cfe6fa27d063",
"classification_code": "B",
"license_type": "building_contractor",
"is_primary": true,
"delisted_at": null
},
{
"id": "5a294f8d-763c-5237-bb75-2229304af229",
"classification_code": "C39",
"license_type": "roofing_contractor",
"is_primary": false,
"delisted_at": null
}
]
}
Continuing education
GET/licenses/{id}/continuing-educationFree · no key
Continuing-education courses completed under a license, most recent first, where the board publishes them.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
Request
curl https://api.licenscheck.com/licenses/67d66956-e16e-4bfb-85f1-02d10a9e58d6/continuing-education
Response
{
"license_id": "67d66956-e16e-4bfb-85f1-02d10a9e58d6",
"records": [
{
"id": "471de7a2-f968-574c-8fef-bcc82019d5b2",
"course_number": "0614919",
"course_name": "DRYWALL REPAIR AND REPLACEMENT",
"hours": 1.0,
"completed_date": "2026-09-04",
"delisted_at": null
},
{
"id": "ec464044-c82a-596a-a00e-cbe2fa3f0bc0",
"course_number": "0615028",
"course_name": "OPTION FOR JOINING MATERIALS",
"hours": 1.0,
"completed_date": "2026-09-04",
"delisted_at": null
},
…
]
}
Link summary
GET/licenses/{id}/link-summaryFree · no key
How many other licenses, in how many states, are linked to the same contractor. Counts only: which licenses, and why, are in linked licenses.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
Request
curl https://api.licenscheck.com/licenses/3a5cdcae-1af7-5c92-89d2-46fc2e0722af/link-summary
Response
{
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"linked_licenses": 1,
"linked_states": 1
}
Verdict
GET/licenses/{id}/verdictPaid plan
Verify a license: Clear, Review or Risk found, weighing its own status, expiration and bond, the board's alerts (pending suspensions, open cases, lapsed insurance or bonds) on it and every license linked to the same contractor, and its licensing history, including any past discipline.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
as_of | query, optional | Verify the license as it stood at a past moment instead of now: a date (2026-10-04, meaning the end of that day, UTC) or an ISO 8601 timestamp. Our record of changes began on October 4, 2026. |
Request
curl -H "Authorization: Bearer $LICENSCHECK_KEY" \
https://api.licenscheck.com/licenses/3a5cdcae-1af7-5c92-89d2-46fc2e0722af/verdict
Response
{
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"level": "review",
"word": "Review",
"summary": "Active in California, but a linked Washington license is suspended.",
"checks": [
{
"label": "Board status",
"outcome": "pass",
"text": "Active as reported by the CA board.",
"segments": [
{ "text": "Active", "strong": true },
{ "text": " as reported by the CA board.", "strong": false }
]
},
{ "label": "Expiration", "outcome": "pass",
"text": "Valid through Jul 31, 2028 (in 1.8 yr).", "segments": […] },
{ "label": "Surety bond", "outcome": "pass",
"text": "$25,000 bond on file (#100504856).", "segments": […] },
{ "label": "Linked licenses", "outcome": "warn",
"text": "Suspended in Washington: SOUTHERN FOLGER CONTRNG INC, linked to this contractor.
1 linked license in WA. Check whether it affects this job.", "segments": […] },
{ "label": "Licensing history", "outcome": "info",
"text": "First issued Jul 13, 2020 (6.2 yr ago).", "segments": […] }
]
}
level is clear, review or risk: any failed check makes it Risk, otherwise any warning makes it Review. This license's own suspension or revocation, or a revoked license linked to the same contractor, fails; a linked license that's suspended or no longer listed warns, since that's usually one license's lapsed bond or insurance. Each check's outcome is pass, warn, fail or info. text is plain text; segments is the same text with the words to emphasize marked, for your own UI.
As of a past date
Add as_of to answer "was this license good on the day we funded?": the same verdict, computed from the license as its board's record stood then, and its linked licenses as they stood then. The licenses linked to the contractor are today's (links aren't kept over time), and unconfirmed possible matches are left out. as_of in the response says what the answer rests on: the last change we recorded to this license on or before that moment, and the board file it came from.
curl -H "Authorization: Bearer $LICENSCHECK_KEY" \
"https://api.licenscheck.com/licenses/3a5cdcae-1af7-5c92-89d2-46fc2e0722af/verdict?as_of=2026-10-04"
{
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"level": "review",
…,
"as_of": {
"at": "2026-10-04T23:59:59.999999Z",
"record_changed_at": "2026-10-04T10:02:41Z",
"record_source_file": "gs://licens-data-raw-01679/CA/2026-10-04/licenses.csv.gz",
"history_since": "2026-10-04T05:51:00Z"
}
}
A date before our record began, or in the future, is a 422; a date before the license first appeared on its board's list is a 404. A change is dated when we read the board's file (daily), not when the board made it.
Linked licenses
GET/licenses/{id}/graphPaid plan
Every other license linked to the same contractor, with how strong each link is and the evidence behind it.
| Name | Type | Description |
|---|---|---|
id | path, UUID | The license's id, from a search result. |
Request
curl -H "Authorization: Bearer $LICENSCHECK_KEY" \
https://api.licenscheck.com/licenses/3a5cdcae-1af7-5c92-89d2-46fc2e0722af/graph
Response
{
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"linked_licenses": [
{
"license": {
"id": "78f68a42-ef6d-52c9-9680-5b024a87e26c",
"contractor_name": "SOUTHERN FOLGER CONTRNG INC",
"license_number": "SOUTHFC804L5",
"state": "WA",
"board": "WA_LNI",
"status": "suspended",
…
},
"confidence": 0.99,
"direct_edge_score": 0.99,
"evidence": [
"same_phone",
"same_name"
],
"related_company": false
}
],
"possible_matches": [],
"possible_match_count": 0
}
confidence: the license's strongest link into the group;direct_edge_score: its direct match with the queried license, ornullwhen it's linked only through other licenses.evidence:same_address,same_phone,same_email,same_business_id,same_principal,same_name, andreviewedwhen a person at licenscheck confirmed the two are one contractor. A shared email links two licenses only when their names match too.related_company:truefor a different business linked to this contractor by a shared address or phone (a sister company, franchise or successor), not the same contractor under another name. Its problems can raise the verdict to Review, never to Risk.possible_matches: up to 10 weaker matches that aren't treated as the same contractor, each with ascore,evidence, andworth_review.possible_match_countis how many there are in all.
Stats
GET/statsFree · no key
How many licenses are on file. licenses_is_exact is false when the count is the database's fast estimate.
Request
curl https://api.licenscheck.com/stats
Response
{
"licenses": 541624,
"licenses_is_exact": false
}
Account
GET/accountFree · any key
Your key's plan and what it has used: verifications this UTC month against the plan's included ones, the contractors you watch against its limit, and on a free key, the searches left today. Reading it uses no verification and no search. The dashboard is built on it.
Request
curl -H "Authorization: Bearer $LICENSCHECK_KEY" https://api.licenscheck.com/account
Response
{
"plan": "starter",
"verifications_used": 61,
"verifications_included": 100,
"overage_cents": 35,
"month_resets_on": "2026-11-01",
"watches_used": 7,
"watches_included": 10,
"searches_left_today": null,
"searches_per_day": null
}
verifications_included and watches_included are null on a plan without a cap. searches_left_today and searches_per_day are null on a paid plan, which has no daily search limit.
Watch a contractor
POST/monitoring/watchesPaid plan
Watching a license watches the whole contractor: that license and every license linked to it, in any state, including ones linked later. Its verdict and licenses are recorded when you start, and from then on every change that matters becomes an event.
What it costs. Monitoring comes with the paid plans: Starter watches up to 10 contractors at once, API up to 50 (talk to us for more). Starting a watch uses one verification, exactly like GET /licenses/{id}/verdict, since the response tells you the contractor's verdict: it comes out of your plan's included verifications, then costs the plan's per-verification price. Verifying a license and starting a watch on it the same UTC day count once. After that, every event for the contractor is included, for as long as you watch it; stopping a watch frees its place.
| Name | Type | Description |
|---|---|---|
license_id | body, UUID | Any license of the contractor, from a search result. |
reference | body, string, optional | Your own label for the watch, 1-200 characters, e.g. your client's and sub's ids. It comes back on the watch and on every event and webhook delivery for it. |
Request
curl -X POST -H "Authorization: Bearer $LICENSCHECK_KEY" \
-H "Content-Type: application/json" \
-d '{"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af", "reference": "client-42/sub-981"}' \
https://api.licenscheck.com/monitoring/watches
Response
{
"id": "070f19f2-f820-4c56-bc2a-1e3bf30bbcbd",
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"contractor_name": "SOUTHERN FOLGER CONTRACTING INC",
"state": "CA",
"board": "CA_CSLB",
"license_number": "1066907",
"reference": "client-42/sub-981",
"linked_states": ["AL", "AR", "AZ", "FL", "MN", "NE", "NV", "NY", "OR", "TN", "WA", "WV"],
"created_at": "2026-10-05T00:52:38Z",
"verdict_level": "clear",
"linked_licenses": 12,
"checked_at": "2026-10-05T00:52:38Z"
}
A new watch is a 201; watching the same license again with the same reference (or none) returns the same watch with a 200 and uses no verification. The same license under a different reference is a separate watch, so a platform can watch one sub for each client that uses it; each watch takes a place in your plan's limit. At that limit, a new watch is a 409 and isn't charged. GET /monitoring/watches lists your watches (limit, offset, and reference to list only one reference's) with each one's licensee, state, number, reference, the states of its linked licenses and its verdict at the last check, plus total (how many match) and included (how many your plan allows), and DELETE /monitoring/watches/{id} stops one; events already sent stay in the feed.
Events
GET/monitoring/eventsPaid plan
What changed for the contractors you watch, oldest first. We check about every 5 minutes after each board's file comes in, and links between licenses are rebuilt nightly. An event is written when a watched contractor's verdict or one of its checks changes, when any of its licenses changes status, is delisted or relisted, or changes expiration date, bond or board alerts, and when a license is linked to it or unlinked. Address, phone and name edits don't send an event.
| Name | Type | Description |
|---|---|---|
after | query, integer | The last event id you've read; 0 for the oldest. |
limit | query, 1-100 | Max events to return (default 50). |
order | query, asc or desc | asc (the default) returns the oldest events after after, for polling; desc returns the newest limit of them, newest first, for showing recent changes. |
reference | query, string | Only events of watches with exactly this reference. |
Request
curl -H "Authorization: Bearer $LICENSCHECK_KEY" \
"https://api.licenscheck.com/monitoring/events?after=0"
Response
{
"events": [
{
"id": 1,
"type": "watch.changed",
"watch_id": "5b0e…",
"license_id": "d9617fd5-b25b-554a-917f-ff38f92c7a0f",
"reference": "client-42/sub-981",
"created_at": "2026-10-05T01:05:26Z",
"data": {
"verdict": {
"before": "risk",
"after": "risk",
"summary": "Expired in Texas, but a linked Texas license is no longer listed by its board."
},
"checks_changed": [],
"changes": [
{
"license_id": "c1e6aac6-e02d-56df-a94c-31147047dec0",
"state": "TX",
"board": "TX_TDLR",
"license_number": "TECL39012",
"name": "THIRD COAST ELECTRIC",
"field": "delisted",
"before": false,
"after": true,
"changed_at": "2026-10-05T01:05:14Z",
"source_file": "gs://licens-data-raw-01679/TX/2026-10-05/licenses.csv.gz"
}
],
"links_added": [],
"links_removed": [],
"possible_matches_added": [],
"possible_matches_removed": []
}
}
],
"next_after": 1
}
Pass next_after as after next time to get only new events; nothing is lost between polls (with order=desc, more than limit new events between calls would skip some, so poll in the default order). In changes, field is status, delisted, expiration_date, bond_number, bond_amount or board_alerts, and source_file is the board file the change came from. A change can be on a license other than the one you watched: here, a linked Texas license was delisted. possible_matches_added and possible_matches_removed name unconfirmed matches worth a look (a similar license that's suspended, revoked or no longer listed, or one sharing an address, phone or business id) as they appear or go, with their status and match score.
Webhooks
POST/monitoring/webhooksPaid plan
Have each event POSTed to your server as it's written: the same JSON as one item of the events feed. The URL must be https on the public internet; a key can have 3.
Request
curl -X POST -H "Authorization: Bearer $LICENSCHECK_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/licenscheck-events"}' \
https://api.licenscheck.com/monitoring/webhooks
Response
{
"id": "a84ee8e0-a476-4c2c-9aca-fec0b3f52a1e",
"url": "https://example.com/licenscheck-events",
"created_at": "2026-10-05T01:20:00Z",
"last_success_at": null,
"secret": "whsec_3f9a…"
}
secret is shown only in this response: store it to verify deliveries. Answer each delivery with any 2xx within 10 seconds. Otherwise we retry after 5 and 30 minutes, then 2, 6 and 24 hours; a webhook that hasn't answered with a 2xx for 3 days is turned off. The events feed has every event either way, so after an outage you can catch up from it. GET /monitoring/webhooks lists yours, DELETE /monitoring/webhooks/{id} removes one, and POST /monitoring/webhooks/{id}/test sends a signed webhook.test delivery right away and tells you how your server answered.
Verifying webhook signatures
Every delivery carries Licenscheck-Event-Id (the event's id: deliveries can repeat, so skip one you've already handled) and Licenscheck-Signature: t=<unix time>,v1=<signature>. The signature is the hex HMAC-SHA256 of <t>.<raw body> under your webhook's secret. Check it against the raw body, before parsing it, and refuse a t more than 5 minutes old.
Python
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
"""Check a Licenscheck-Signature header against the raw request body."""
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance:
return False # too old: a replay
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Node
const crypto = require("crypto");
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((item) => item.split("=")));
const timestamp = Number(parts.t);
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false; // too old: a replay
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify a spreadsheet
POST/batchesPaid plan
Send a CSV of contractors (up to 1,000 rows) and get a verdict for every row. It works in three steps, and nothing is charged until you run it. The website does the same steps for you.
- Upload.
POST /batches?filename=subs.csvwith the file as the request body (Content-Type: text/csv, UTF-8). You get back a draft: its columns, what each one is read as (license_number,name,state,dba,phone,addressorkeep, guessed from the headers; every street, city and ZIP column isaddress), how many rows match, andcost, the most running it can add to your bill. - Check. If a column was read wrong,
POST /batches/{id}/previewwith{"roles": [...]}, one role per column, and get the new matches and cost. - Run.
POST /batches/{id}/runwith the same roles. It usually finishes within a minute: pollGET /batches/{id}untilstatusisdone.
Request
curl -X POST -H "Authorization: Bearer $LICENSCHECK_KEY" \
-H "Content-Type: text/csv" --data-binary @subs.csv \
"https://api.licenscheck.com/batches?filename=subs.csv"
How rows match
A row is matched by its license number (with its state, when given) or by its exact name, ignoring punctuation, case and legal endings. Its DBA column, and either side of a name like "Brothers Plumbing Corp dba Mr. Rooter", are tried as names too. A phone or address never finds a license on its own: it only chooses between contractors that share the row's name (the one at the same phone or street address, or in the same city when every other one is listed elsewhere), and confirms a close name, such as a typo, at the same phone or street address. Each row ends in one of four ways:
matched: one license, or several licenses of one linked contractor (we verify its active one). It's verified, for one verification.ambiguous: several contractors fit, for example a common name in one state, and the row's phone and address don't pick one out. We never guess. The row lists up to 3candidates, andPOST /batches/{id}/rows/{row}/resolvewith{"license_id": ...}verifies the one you pick.not_found: nothing matches. When a name is close to others, they're offered ascandidates.invalid: no license number and no name, or a state we can't read.
Only matched rows are charged, one verification per license, and a license you already verified that UTC day costs nothing again.
Results
GET /batches/{id}/rows?view=flagged (Risk and Review), clear, help (ambiguous, not found and invalid) or all pages through the rows: your values by column, the match, and each verified row's result (verdict, summary, failing checks, linked licenses and their states). GET /batches/{id}/results.csv is your file back with those results added as columns. Any cell that would start a spreadsheet formula (=, +, -, @) gets a leading apostrophe. POST /batches/{id}/watch with {"rows": [...]} starts watches on those rows' contractors, up to your plan's limit. GET /batches lists your batches.
Limits
A file can be up to 2 MB, 1,000 rows, 50 columns and 500 characters a cell, in UTF-8 (Excel: "CSV UTF-8"). A key can run 5 batches a day. We keep only the cell values, never the file, and delete them 90 days after the upload, or after a day if the batch never runs.
Issue a proof report
POST/reportsPaid plan
A PDF of a license's verdict, its record and its linked licenses as they stood at a moment, for a loan file or a contract record. It carries a code and a QR code linking to a public page where anyone can confirm the copy they hold is the one we issued. Issuing one uses one verification of the license (none if you already verified it that UTC day).
| Name | Type | Description |
|---|---|---|
license_id | body, UUID | The license to report on. |
as_of | body, string | Optional: an ISO 8601 timestamp (no zone means UTC) or a date (its end, UTC). Omit for now. Our record of changes began 2026-10-04 05:51 UTC, so earlier moments are a 422. |
Request
curl -X POST -H "Authorization: Bearer $LICENSCHECK_KEY" \
-H "Content-Type: application/json" \
-d '{"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af", "as_of": "2026-10-08T13:00:00Z"}' \
https://api.licenscheck.com/reports
Response
{
"id": "a642c242-673b-4f82-85d3-15ccda5d7c8f",
"code": "5YTQK-Y3X9H",
"license_id": "3a5cdcae-1af7-5c92-89d2-46fc2e0722af",
"contractor_name": "SOUTHERN FOLGER CONTRACTING INC",
"state": "CA",
"license_number": "1066907",
"as_of": "2026-10-08T13:00:00Z",
"issued_at": "2026-10-11T23:00:13Z",
"level": "clear",
"sha256": "2ab1eeacb83cccfaf3ed228e7093f7a074d8119c09fd6c73abf01999fe40dff0",
"verify_url": "https://licenscheck.com/verify/5YTQK-Y3X9H"
}
GET /reports/{id}.pdf downloads the PDF, byte for byte as issued (we keep it, so its fingerprint always holds), and GET /reports lists your reports. A license that wasn't on its board's list yet at that moment is a 404. Links between licenses are today's, each license shown as it stood at the moment.
Check a report
GET/verify/{code}Free · no key
What a report says, by the code printed on it (any case, dash optional), and the SHA-256 of the PDF we issued. Hash the copy you were given: if it equals sha256, it hasn't been changed. Only what the report prints is returned. The same check runs in the browser at licenscheck.com/verify.
Request
curl https://api.licenscheck.com/verify/5YTQK-Y3X9H
Use with AI agents
MCPhttps://api.licenscheck.com/mcpAPI key
The same verification as the API, as tools an AI agent can call over the Model Context Protocol (Streamable HTTP). Send your key as a Bearer token, as with the API. Agents get the verdict with its reasons, a contractor's licenses in other states, proof reports and monitoring, on your plan's meter: a license checked by an agent and through the API the same UTC day is one verification.
| Tool | Plan | What it does |
|---|---|---|
find_contractor | Any key | Finds a license by number and/or name and state: one match, several contractors to ask about, or the nearest names. On a free key it uses one of the day's 50 searches. |
get_license | Any key | A license record and its classifications. Free. |
check_contractor | Paid plan | The verdict and its checks, the linked licenses and possible matches worth a look; as_of for a past moment. One verification. |
check_contractors | Paid plan | Up to 100 contractors in one call, saved as a batch. One verification per matched license. |
create_proof_report | Paid plan | A proof report and its public verify link. One verification. |
watch_contractor, stop_watching, get_watchlist | Paid plan | Monitoring: start or end a watch, and read the watched contractors and what changed. Starting a watch uses one verification. |
get_account | Any key | Your plan and what's left this month. Free. |
Claude Code
claude mcp add --transport http licenscheck https://api.licenscheck.com/mcp \
--header "Authorization: Bearer $LICENSCHECK_KEY"
Cursor, VS Code and other clients
{
"mcpServers": {
"licenscheck": {
"url": "https://api.licenscheck.com/mcp",
"headers": { "Authorization": "Bearer ${env:LICENSCHECK_KEY}" }
}
}
}
VS Code names the top-level key servers. Agent SDKs (Claude, OpenAI, LangChain) take the same URL and header. Without a key, or with a wrong one, /mcp answers 401. Every tool returns structured JSON plus a few lines the agent can quote, with contractor names quoted as data, never as instructions.
About the data
- Straight from the boards, every day. Each record comes from the state board's own published data, refreshed daily (continuing education weekly), and
last_verified_atshows exactly when we last checked it. Status is exactly what the board reports. - Every link shows its evidence. We link a contractor's licenses on shared names, addresses, phones and business ids, and the
evidenceon each link says which, so you can see why. - A license's
iddoesn't change when the board data refreshes, so it's safe to store. - For the businesses you hire or work with. Don't use the API to decide whether a person gets credit, insurance, a job or housing: our results aren't consumer reports (see the Terms and Acceptable use).