TradesAPI reference.
One normalized API for contractor license verification across all 50 states + DC, plus 9 municipal licensing boards. Send a license number, state, and trade — get back validity, expiration, status, and disciplinary history in the same shape everywhere.
Introduction.
The API is served from https://www.tradesapi.com. All endpoints return JSON. The core primitive is a verification: a lookup of one license against the authoritative state (or city) licensing source, normalized into a single response schema.
- Coverage — all 50 states + DC at the state level, plus city boards in Chicago, New York City, Philadelphia, Detroit, Atlanta, Dallas, Nashville, Las Vegas, and Jacksonville.
- Freshness you can audit — every response carries
checked_atandcached. Active results may be served from cache for up to 24 hours; expired, not-found, and error results are always re-verified. Passfresh=trueto force a live check. - Credits — one credit per successful verification, two per name search. Failed lookups and unsupported jurisdictions are not charged. New keys include 50 free verifications.
Quickstart.
Sign in at /auth/login (Google or email code), copy your API key from the dashboard, and verify your first license:
curl "https://www.tradesapi.com/verify?state=TX&license=TACLA00000103C&trade=hvac" \ -H "X-API-Key: YOUR_KEY"
const res = await fetch( "https://www.tradesapi.com/verify?state=TX&license=TACLA00000103C&trade=hvac", { headers: { "X-API-Key": process.env.TRADESAPI_KEY } } ); const license = await res.json();
import httpx
res = httpx.get(
"https://www.tradesapi.com/verify",
params={"state": "TX", "license": "TACLA00000103C", "trade": "hvac"},
headers={"X-API-Key": TRADESAPI_KEY},
)
license = res.json()
Want to wire up CI first? The sandbox serves deterministic fixture responses on the same endpoints at zero credit cost.
Authentication.
Every authenticated endpoint takes your key in the X-API-Key request header. Keys are shown once at creation and stored hashed — treat them like passwords and rotate from the dashboard if one leaks.
Two key scopes matter for integration work:
user— production keys. Verifications charge credits and hit live sources.sandbox— test keys. Same endpoints and response schema, deterministic fixtures, zero credit cost. See sandbox testing.
The hosted MCP endpoint additionally supports an OAuth 2.1 browser flow — see MCP server.
Errors.
Errors return a consistent JSON body:
{
"error": "...",
"detail": "..."
}
| Status | Meaning | Notes |
|---|---|---|
| 400 | bad request | Invalid or missing parameters, unsupported jurisdiction. |
| 401 | unauthorized | Missing or invalid X-API-Key. |
| 402 | payment required | Insufficient credit balance for the request. |
| 429 | rate limited | Per-key rate limit exceeded; honor Retry-After. |
| 502 | upstream failure | Verification temporarily unavailable. The detail includes a short correlation reference you can quote to support; internal error detail is never exposed. |
Rate limits.
Requests are rate-limited per API key with a Redis sliding window (default 100 requests/minute). Every response carries the standard headers:
X-RateLimit-Limit— your per-minute ceilingX-RateLimit-Remaining— requests left in the current windowX-RateLimit-Reset— when the window resets
On 429, back off until Retry-After. Sandbox keys share the same limit config, so you can tune request pacing in CI before pointing at production.
Verify a license.
Look up one license by state, license number, and trade. Returns the normalized LicenseResult. One credit per successful verification.
Query parameters
| Parameter | Type | Description |
|---|---|---|
| state | string, required | Two-letter state code, e.g. TX, CA. |
| license | string, required | License number as issued by the licensing board. |
| trade | string | Trade type, e.g. hvac, plumbing, electrical. Defaults to general. |
| city | string | Optional municipality slug (e.g. houston, chicago) to target a city licensing board instead of the state. Composes into a jurisdiction like TX_houston. |
| fresh | boolean | Force a live check, bypassing the cache and the DB-first read layer. Same one-credit cost. Default false. |
| license_type | string | Roster filter for states that publish parallel rosters (currently Mississippi): commercial or residential. Other states ignore it. |
Response
The same shape for every jurisdiction — your integration code doesn't change per state:
{
"valid": true,
"name": "SAMPLE HVAC SERVICES LLC",
"license_number": "TACLA00000103C",
"trade": "hvac",
"expiration": "05/12/2027",
"status": "Active",
"state": "TX",
"disciplinary_actions": [],
"source_url": "https://www.tdlr.texas.gov/LicenseSearch/",
"cached": false,
"checked_at": "2026-07-07T15:04:00Z"
}
valid— whether the license is currently valid for the trade.status— the board's own status wording (e.g.Active,Expired,Suspended).disciplinary_actions— enforcement actions when the board publishes them, in the same response. No second lookup.cached/checked_at— freshness audit trail for this answer.
Municipal lookups
Some trades are licensed by the city, not the state. Add city to route to the municipal board under the same schema:
curl "https://www.tradesapi.com/verify?state=IL&city=chicago&license=EXAMPLE123&trade=general" \ -H "X-API-Key: YOUR_KEY"
Batch verification.
Verify up to 200 licenses in one request, run in parallel. Partial failures don't block other items — each result is returned independently, and credits are charged only for successful verifications. For larger workloads, chunk into 200-item calls client-side.
curl -X POST "https://www.tradesapi.com/batch" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "licenses": [ { "state": "TX", "license": "TACLA00000103C", "trade": "hvac" }, { "state": "CA", "license": "1012345", "trade": "general" } ] }'
{
"total": 2,
"succeeded": 2,
"failed": 0,
"results": [
{ "result": { ... LicenseResult ... }, "error": null },
{ "result": { ... LicenseResult ... }, "error": null }
],
"credits_charged": 2
}
Results come back in the same order as the request. Credit deduction is atomic — you're never partially charged for a failed item.
Name search.
Find licenses by business or individual name when the license number is unknown, in states whose portals support name search (see supports_name_search on GET /states). Costs 2 credits per search.
| Parameter | Type | Description |
|---|---|---|
| state | string, required | Two-letter state code. |
| name | string, required | Business or individual name, 2–200 characters. |
| trade | string | Trade type. Defaults to general. |
| limit | integer | Max results, 1–50. Defaults to 20. |
| city | string | Optional municipality slug to search a city board instead. |
{
"query": { "state": "TX", "city": null, "name": "Sample", "trade": "hvac" },
"total_results": 1,
"results": [
{
"name": "SAMPLE HVAC SERVICES LLC",
"license_number": "TACLA00000103C",
"trade": "hvac",
"status": "Active",
"state": "TX",
"confidence": 0.95,
"source_url": "https://www.tdlr.texas.gov/LicenseSearch/"
}
],
"cached": false,
"checked_at": "2026-07-07T15:04:00Z"
}
Chain search-then-verify: take a license_number from the results and pass it to /verify for the full normalized record.
Supported jurisdictions.
Public, no auth. Returns every supported jurisdiction with integration metadata — one row per state, with municipal boards nested under their parent state:
code,name,portal_urlsupported_trades— trades observed for the jurisdiction (best effort)supports_name_search— whether/searchworks therestatus—healthy|degraded|maintenancemunicipalities— nested city rows, same shape withcityin place ofname
For live operational status, the status page and /status.json refresh continuously and are safe for uptime monitors.
Credit balance.
Returns your key's current credit balance and cumulative usage:
{
"credit_balance": 412,
"usage_count": 88,
"owner": "you@example.com"
}
Credit packs are purchased from the dashboard; credits never expire.
Monitoring watch lists.
A /verify call is a snapshot. Monitoring keeps watching: add a license to your watch list and it is re-checked against source-of-truth data on an ongoing basis; when its status, expiration, name, trade, or classifications change, you're notified by webhook or email.
| Field | Type | Description |
|---|---|---|
| jurisdiction | string, required | State code or municipal composite, e.g. TX or TX_houston. |
| license_number | string, required | License to watch. |
| label | string | Optional free-text label (up to 500 chars) — your vendor ID, project name, etc. |
| notify | object | Optional: webhook_subscription_ids (IDs of your webhook subscriptions) and/or email. |
Returns 201 for a new monitor, 200 on an idempotent re-POST of an existing one, 400 for an uncovered jurisdiction, and 429 when your active-monitor quota is exhausted.
curl -X POST "https://www.tradesapi.com/monitor" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "jurisdiction": "TX", "license_number": "TACLA00000103C", "label": "vendor-1042", "notify": { "webhook_subscription_ids": [1] } }'
Bulk import.
Load a whole roster at once: POST a CSV body (Content-Type: text/csv) with a header row. Recognized columns (case-insensitive): jurisdiction, license_number, optional label. Extra columns are ignored. Up to 5,000 rows per request.
curl -X POST "https://www.tradesapi.com/monitor/bulk" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: text/csv" \ --data-binary $'jurisdiction,license_number,label\nTX,TACLA00000103C,vendor-1042\nCA,1012345,vendor-2210'
Always returns 200 with per-row outcomes (created | existed | error | quota_exceeded) so one bad row never fails the import.
Manage monitors.
GET /monitor— list your monitors, paginated (limitup to 100).GET /monitor/usage— active-monitor count against your quota, for previewing the bill.GET /monitor/{id}— one monitor's detail.PATCH /monitor/{id}— updatelabelornotifyconfig.DELETE /monitor/{id}— stop watching (returns204). Re-POSTing the same license later reactivates it.
The whole roster is also manageable from the dashboard.
Webhook subscriptions.
Create a subscription with the HTTPS URL you want events delivered to, plus an optional event filter and description. The response includes your signing_secret — shown exactly once; store it to verify deliveries.
curl -X POST "https://www.tradesapi.com/webhooks/subscriptions" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/tradesapi", "description": "prod" }'
Event types use a dotted scheme so you can pattern-match on the license. prefix:
license.status_changedlicense.expiration_changedlicense.classifications_changedlicense.name_changedlicense.trade_changed
Useful extras: POST /webhooks/subscriptions/{id}/test sends a test event to your endpoint, and POST /webhooks/subscriptions/{id}/rotate-secret issues a new secret (the previous one stays valid for a grace window so you can roll over without dropped verifications).
Verifying signatures.
Every delivery is signed. Headers on each POST to your endpoint:
| Header | Description | |
|---|---|---|
| X-Tradesapi-Signature | t=<unix-timestamp>,v1=<hex-hmac> | |
| X-Tradesapi-Event-Id | Stable event ID — deduplicate on this. | |
| X-Tradesapi-Subscription-Id | Which subscription this delivery belongs to. | |
| X-Tradesapi-Delivery-Attempt | Attempt number (1 on first try). |
To verify: compute HMAC-SHA256(secret, "<t>." + raw_body) and compare the hex digest against v1 in constant time. Sign over the exact raw bytes you received — re-encoding the JSON will invalidate the signature. Reject stale timestamps to prevent replay.
import hashlib, hmac
def verify(header: str, body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
expected = hmac.new(
secret.encode(), f"{t}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
import crypto from "node:crypto"; function verify(header, rawBody, secret) { const parts = Object.fromEntries(header.split(",").map(p => p.split("="))); const expected = crypto .createHmac("sha256", secret) .update(`${parts.t}.`).update(rawBody) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); }
Deliveries & retries.
Failed deliveries are retried automatically with backoff. The deliveries feed lists recent attempts with status codes and timings so you can audit what reached your endpoint, and a redeliver action lets you replay a specific delivery after fixing your receiver. Respond with any 2xx quickly (do heavy work async) — anything else counts as a failure and schedules a retry.
MCP server.
TradesAPI speaks Model Context Protocol, so Claude, Cursor, and any MCP-compatible agent can verify licenses directly.
Hosted (recommended)
Point your MCP client at the hosted endpoint — connect with just the URL and complete the OAuth 2.1 browser flow, or pass an API key as a bearer token:
{
"mcpServers": {
"tradesapi": {
"type": "streamable-http",
"url": "https://www.tradesapi.com/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}
Local stdio
Prefer to run it locally? The open-source server is on npm:
{
"mcpServers": {
"tradesapi": {
"command": "npx",
"args": ["-y", "contractor-license-mcp-server"],
"env": {
"CLV_API_URL": "https://www.tradesapi.com",
"CLV_API_KEY": "your-api-key-here"
}
}
}
}
Tools exposed: single verify, batch verify (up to 25 per call), and name search. Source on GitHub; listed in the MCP Registry.
Sandbox testing.
Sandbox keys hit the same endpoints (/verify, /batch, /search) with deterministic fixture responses keyed by license-number prefix (SANDBOX-VALID-*, SANDBOX-EXPIRED-*, SANDBOX-DISCIPLINARY-*, ...). Zero credit cost, safe to assert against in CI, and every response carries "sandbox": true plus an X-Sandbox: true header.
Full sentinel table and examples: sandbox guide.
OpenAPI & interactive explorer.
- Interactive explorer (Swagger UI) — try requests against your key in the browser.
- ReDoc — alternative rendered reference.
/openapi.json— the machine-readable schema, for codegen.