Zabalist API · v1
Texas construction data API
Building permits, statewide TDLR project registrations, contractor entity resolution with live license status, 888,179 work-history links, and every Texas government solicitation — behind one REST API with a free tier.
- 256
- Texas counties with project data
- 254
- Counties of government bids
- 888,179
- Contractor work-history links
- 142,781
- Deduplicated contractor entities
Jump to a section
Getting started
Introduction
The Zabalist API is a REST API over Texas construction data. Every response is JSON, every endpoint is a GET, and every list response uses the same pagination envelope. There is no SDK to install and no OAuth dance — a key in a header is the whole integration.
What is distinctive here is not the row count. National permit vendors have more rows. What we own is the cross-source join: TDLR statewide project registrations, a deduplicated contractor entity graph, statewide government bids with SBA and HUB certification enrichment, and Travis County appraisal and deed history — resolved to the same contractor identities so a name on a permit, a party on a state filing, and a bidder on a solicitation are provably the same company.
https://www.zabalist.com/api/public/v1Your first call takes about thirty seconds
Create a free key, export it, and hit the permits endpoint. The Free plan includes 500 calls per month with no card on file.
export ZABALIST_API_KEY="zbl_live_..."
curl -G "https://www.zabalist.com/api/public/v1/permits" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d zip=78745 \
-d limit=5const res = await fetch(
'https://www.zabalist.com/api/public/v1/permits?zip=78745&limit=5',
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const { data, meta } = await res.json();
console.log(data[0].permit_number, data[0].address.street);import os, requests
res = requests.get(
"https://www.zabalist.com/api/public/v1/permits",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
params={"zip": "78745", "limit": 5},
timeout=30,
)
print(res.json()["data"][0]["permit_number"])Coverage is Texas, and it is uneven on purpose
Project registrations and government bids are statewide — 256 and 254 counties respectively. Building permits are City of Austin on a rolling two-year window, and property history is Travis County only. We publish this rather than bury it: an integration built on a coverage assumption we never stated is an integration that churns in month two.
Ready to build?
Free tier: 500 calls/month, no card required.
Getting started
Authentication
Authenticate every request with your secret key in the X-API-Key header. Keys are issued from your API dashboard.
Keys look like zbl_live_7Kq2xM…. Only the prefix is stored in readable form — the secret is hashed, so the full key is shown exactly once, at creation. If you lose it, revoke it and create another; we cannot recover it for you, and an API provider that can show you your own secret later is one that is storing it badly.
Rate limits, quotas, and Contractor Verify allowances are counted per key, not per IP. Running on shared serverless egress does not collapse you into a bucket with someone else, and issuing a key per environment or per worker is the intended pattern.
curl "https://www.zabalist.com/api/public/v1/usage" \
-H "X-API-Key: $ZABALIST_API_KEY"// Reuse one client so the header is set in exactly one place.
const zabalist = (path, params = {}) =>
fetch(
`https://www.zabalist.com/api/public/v1${path}?${new URLSearchParams(params)}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
).then(async (res) => {
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body;
});
await zabalist('/permits', { zip: '78745' });import os, requests
session = requests.Session()
session.headers["X-API-Key"] = os.environ["ZABALIST_API_KEY"]
# Every call on this session is now authenticated.
usage = session.get(
"https://www.zabalist.com/api/public/v1/usage", timeout=30
).json()Key properties
| Field | Type | Description |
|---|---|---|
prefix | string | First characters of the key, e.g. zbl_live_7Kq2xM. Shown in the dashboard and returned by /usage so you can tell keys apart. |
name | string | Label you assign at creation, e.g. "production-worker". Purely for your own bookkeeping. |
secret | string | The full key. Returned exactly once, at creation, and never again. |
created_at | string | ISO 8601 timestamp the key was issued. |
Never ship a key to a browser
These are secret keys with your quota and your billing attached. Call the API from your server, a serverless function, or a backend job — never from client-side JavaScript or a mobile app binary. Revoke immediately from the dashboard if a key leaks; revocation takes effect on the next request.
{
"error": {
"code": "invalid_api_key",
"message": "No valid API key found. Pass your key in the X-API-Key header.",
"docs": "https://www.zabalist.com/developers/docs"
}
}Ready to build?
Free tier: 500 calls/month, no card required.
Getting started
Rate limits & quotas
Two independent limits apply. A rate limit caps sustained requests per minute, and a monthly quota caps total calls in a billing period. Both are enforced per key and both are visible at any time from the usage endpoint, which is itself free and unmetered.
Contractor Verify carries a third allowance. Verify calls are entity-resolution joins rather than row lookups, so each plan includes a number of them and additional lookups bill at $0.15 each. Every other endpoint counts only against your monthly call quota.
| Plan | Price | Rate limit | Calls / month | Verify included |
|---|---|---|---|---|
| Free | Free | 10 req/min | 500 | — |
| Developer | $49/mo | 60 req/min | 10,000 | 500 |
| Growth | $149/mo | 300 req/min | 100,000 | 5,000 |
| Scale | $399/mo | 600 req/min | 500,000 | 25,000 |
Rate-limit response headers
| Field | Type | Description |
|---|---|---|
X-RateLimit-Limit | integer | Requests permitted per minute on your plan. |
X-RateLimit-Remaining | integer | Requests left in the current minute window. |
X-RateLimit-Reset | integer | Unix timestamp when the window resets. |
X-Quota-Remaining | integer | Calls left in the current billing period. |
Retry-After | integer | Seconds to wait. Present only on a 429. |
async function withRetry(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.status !== 429) return res;
// Honour Retry-After when present; otherwise back off exponentially.
const wait = Number(res.headers.get('Retry-After') ?? 2 ** i);
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error('Rate limited after all retries');
}
const res = await withRetry(() =>
fetch('https://www.zabalist.com/api/public/v1/permits?zip=78745', {
headers: { 'X-API-Key': process.env.ZABALIST_API_KEY },
})
);import os, time, requests
def get(path, **params):
url = f"https://www.zabalist.com/api/public/v1{path}"
headers = {"X-API-Key": os.environ["ZABALIST_API_KEY"]}
for attempt in range(5):
res = requests.get(url, headers=headers, params=params, timeout=30)
if res.status_code != 429:
res.raise_for_status()
return res.json()
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
raise RuntimeError("rate limited after all retries")# Inspect your remaining budget without spending a call.
curl -sS -D - -o /dev/null \
"https://www.zabalist.com/api/public/v1/usage" \
-H "X-API-Key: $ZABALIST_API_KEY" | grep -i "^x-\(ratelimit\|quota\)"
# X-RateLimit-Limit: 300
# X-RateLimit-Remaining: 297
# X-RateLimit-Reset: 1786320000
# X-Quota-Remaining: 61588{
"error": {
"code": "rate_limited",
"message": "Rate limit of 60 requests/minute exceeded for this key.",
"docs": "https://www.zabalist.com/developers/docs"
}
}Ready to build?
Free tier: 500 calls/month, no card required.
Getting started
Pagination
Every list endpoint returns the same two-key envelope: data holds the results and meta holds the cursor. Single-object endpoints such as Contractor Verify return the object at the top level instead — there is nothing to page through.
Pagination is cursor-based, not offset-based. This data is re-ingested nightly, so an offset walked over ten pages would silently skip or duplicate rows as inserts land underneath it. Pass meta.next_cursor back as the cursor parameter and stop when it comes back null.
meta object
| Field | Type | Description |
|---|---|---|
count | integer | Number of records in this page’s data array. |
has_more | boolean | Whether more pages exist. |
next_cursor | string | null | Opaque cursor for the next page. Null on the last page. Do not parse it — the encoding is not part of the contract. |
{
"data": [ /* ...records... */ ],
"meta": {
"count": 25,
"has_more": true,
"next_cursor": "eyJpIjoicHJtXzAxSlE4RjNYSzJWTiJ9"
}
}async function* paginate(path, params) {
let cursor = null;
do {
const qs = new URLSearchParams({ ...params, limit: '100' });
if (cursor) qs.set('cursor', cursor);
const { data, meta } = await fetch(
`https://www.zabalist.com/api/public/v1${path}?${qs}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
).then((r) => r.json());
yield* data;
cursor = meta.next_cursor;
} while (cursor);
}
for await (const permit of paginate('/permits', { zip: '78745' })) {
console.log(permit.permit_number);
}import os, requests
def paginate(path, **params):
session = requests.Session()
session.headers["X-API-Key"] = os.environ["ZABALIST_API_KEY"]
params["limit"] = 100
cursor = None
while True:
if cursor:
params["cursor"] = cursor
body = session.get(
f"https://www.zabalist.com/api/public/v1{path}",
params=params, timeout=30,
).json()
yield from body["data"]
cursor = body["meta"]["next_cursor"]
if not cursor:
return
for permit in paginate("/permits", zip="78745"):
print(permit["permit_number"])# Page one.
curl -G "https://www.zabalist.com/api/public/v1/permits" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d zip=78745 -d limit=100
# Page two — feed meta.next_cursor straight back in.
curl -G "https://www.zabalist.com/api/public/v1/permits" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d zip=78745 -d limit=100 \
-d cursor=eyJpIjoicHJtXzAxSlE4RjNYSzJWTiJ9Getting started
Errors
Errors use conventional HTTP status codes and always return the same envelope: a single error object with a stable machine-readable code and a human-readable message. Branch on the code; the message is written for a person reading a log and may be reworded.
error object
| Field | Type | Description |
|---|---|---|
code | string | Stable machine-readable identifier. Safe to switch on. |
message | string | Human-readable explanation. Not a stable contract. |
details | object | null | Present on validation and unsupported-parameter errors. Keyed by the offending parameter, with the reason and the workaround. |
docs | string | Link to this API reference. |
{
"error": {
"code": "invalid_request",
"message": "One or more query parameters are invalid.",
"details": {
"issued_after": "Expected an ISO date, YYYY-MM-DD"
},
"docs": "https://www.zabalist.com/developers/docs"
}
}| Status | Code | What it means |
|---|---|---|
| 400 | invalid_request | A query parameter was missing, malformed, out of range, or not supported on this endpoint. error.details names the parameter and the workaround. |
| 401 | missing_api_key | No key was sent. Supply X-API-Key (or Authorization: Bearer). |
| 401 | invalid_api_key | The key is unknown or malformed. |
| 403 | key_revoked | This key was revoked. Revocation is permanent — issue a new key. |
| 403 | key_disabled | This key is on a reversible hold (non-payment or abuse). Contact support. |
| 404 | not_found | No record with that id, or it is outside your plan’s coverage. |
| 429 | quota_exceeded | Monthly call quota exhausted. Resets 00:00 UTC on the 1st. Honour Retry-After. |
| 429 | rate_limited | Too many requests per minute for your plan. Honour Retry-After. |
| 500 | upstream_error | Something broke on our side. Safe to retry with backoff. |
| 503 | upstream_error | A dependency is unavailable. Retry with backoff. |
const res = await fetch(url, {
headers: { 'X-API-Key': process.env.ZABALIST_API_KEY },
});
const body = await res.json();
if (!res.ok) {
switch (body.error.code) {
case 'rate_limited':
// Retry-After is an HTTP header, not a body field.
return retryAfter(Number(res.headers.get('Retry-After') ?? 60));
case 'quota_exceeded':
return alertOps(body.error.message);
case 'key_revoked':
case 'key_disabled':
return alertOps(body.error.message);
case 'invalid_request':
// error.details names the offending parameter and the workaround.
throw new Error(JSON.stringify(body.error.details));
default:
throw new Error(`${body.error.code}: ${body.error.message}`);
}
}res = session.get(url, params=params, timeout=30)
body = res.json()
if not res.ok:
code = body["error"]["code"]
if code == "rate_limited":
# Retry-After is an HTTP header, not a body field.
time.sleep(int(res.headers.get("Retry-After", 60)))
elif code == "quota_exceeded":
raise BudgetExceeded(body["error"]["message"])
elif code in ("key_revoked", "key_disabled"):
raise AuthError(body["error"]["message"])
elif code == "invalid_request":
# error.details names the offending parameter and the workaround.
raise ValueError(body["error"].get("details"))
else:
raise RuntimeError(f"{code}: {body['error']['message']}")Endpoint
Permits
/api/public/v1/permitsReturns building permits sourced from the City of Austin open-data portal, joined to the deduplicated contractor entity graph so every permit carries a resolvable contractor identity rather than a raw name string.
Coverage is City of Austin on a rolling two-year window. This is a deliberate, documented limit rather than a gap we are hiding: statewide permit ingestion for DFW, Houston, and San Antonio is in progress, and this endpoint will widen without a breaking change when it lands. If you need statewide coverage today, use Projects — TDLR project registrations cover 256 Texas counties.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
zip | string | optional | Five-digit ZIP code, e.g. 78745. Repeatable for multiple ZIPs. |
address | string | optional | Free-text street address. Normalized and fuzzy-matched, so "1204 Cedar Bend" and "1204 CEDAR BEND DR" both resolve. |
permit_type | string | optional | Permit class, e.g. building, electrical, mechanical, plumbing, driveway. Repeatable. |
status | string | optional | One of active, final, expired, withdrawn, voided. Underwriting and prequal workflows almost always want status=final. |
issued_after | string (ISO 8601 date) | optional | Only permits issued on or after this date, e.g. 2025-01-01. |
issued_before | string (ISO 8601 date) | optional | Only permits issued on or before this date. |
contractor_id | string | optional | NOT YET SUPPORTED — returns 400 with an explanatory error.details. The permits dataset carries a contractor name string and no foreign key into the entity graph, and fuzzy-guessing that link on a prequal-adjacent endpoint is not a trade-off we are willing to make silently. Use /contractors/{id}/history?record_type=permit for a contractor-scoped permit list today. |
min_valuation | integer | optional | Minimum declared job valuation in whole US dollars. |
limit | integer | optional | Results per page. 1–100, default 25. |
cursor | string | optional | Opaque cursor from meta.next_cursor. Pass it back verbatim to fetch the next page and stop when it comes back null. Treat the token as opaque: it is positional today, so rows inserted between two page fetches can shift a row across a page boundary. For a point-in-time-consistent sweep of a changing dataset, pin the window with the date filters (e.g. issued_before) rather than relying on the cursor alone. |
Response fields
| Field | Type | Description |
|---|---|---|
id | string | Stable Zabalist permit id. Safe to store as a foreign key. |
permit_number | string | Issuing jurisdiction’s permit number, verbatim. |
jurisdiction | string | Issuing authority, e.g. "City of Austin". |
permit_type | string | Normalized permit class. |
work_class | string | Jurisdiction work description, e.g. "New", "Remodel", "Addition". |
description | string | null | Scope of work as filed. |
status | string | active | final | expired | withdrawn | voided. |
issued_date | string | null | ISO 8601 date the permit was issued. |
finaled_date | string | null | ISO 8601 date the permit was finaled. Null until inspection closes. |
valuation | integer | null | Declared job valuation in whole US dollars. |
address | object | street, city, state, zip, county. |
location | object | null | latitude and longitude, WGS 84. Null when the address could not be geocoded. |
contractor | object | null | id, name, and tdlr_license of the resolved contractor entity. Pass contractor.id to Contractor History. |
source | object | Provenance: name, url, and retrieved_at. Every row is traceable to a government source. |
curl -G "https://www.zabalist.com/api/public/v1/permits" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d zip=78745 \
-d permit_type=building \
-d status=final \
-d issued_after=2025-01-01 \
-d limit=25const params = new URLSearchParams({
zip: '78745',
permit_type: 'building',
status: 'final',
issued_after: '2025-01-01',
limit: '25',
});
const res = await fetch(
`https://www.zabalist.com/api/public/v1/permits?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const { data, meta } = await res.json();
console.log(`${data.length} permits, next: ${meta.next_cursor}`);import os, requests
res = requests.get(
"https://www.zabalist.com/api/public/v1/permits",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
params={
"zip": "78745",
"permit_type": "building",
"status": "final",
"issued_after": "2025-01-01",
"limit": 25,
},
timeout=30,
)
res.raise_for_status()
body = res.json()
for permit in body["data"]:
print(permit["permit_number"], permit["address"]["street"]){
"data": [
{
"id": "prm_01JQ8F3XK2VN7RTMB4ZC9WYD5H",
"permit_number": "2025-041882 BP",
"jurisdiction": "City of Austin",
"permit_type": "building",
"work_class": "New",
"description": "New single family residence with attached garage",
"status": "final",
"issued_date": "2025-03-14",
"finaled_date": "2025-11-06",
"valuation": 428500,
"address": {
"street": "1204 Cedar Bend Dr",
"city": "Austin",
"state": "TX",
"zip": "78745",
"county": "Travis"
},
"location": { "latitude": 30.21174, "longitude": -97.79432 },
"contractor": {
"id": "ent_7QK4M2XPB9F3TRWN",
"name": "Sendero Custom Homes LLC",
"tdlr_license": null
},
"source": {
"name": "City of Austin Open Data Portal",
"url": "https://data.austintexas.gov/d/3syk-w9eu",
"retrieved_at": "2026-08-07T09:14:22Z"
}
}
],
"meta": {
"count": 25,
"has_more": true,
"next_cursor": "eyJ2IjoxLCJvIjoyNX0"
}
}Ready to build?
Free tier: 500 calls/month, no card required.
Endpoint
Projects
/api/public/v1/projectsStatewide Texas project registrations from the Texas Department of Licensing and Regulation, covering commercial and accessibility-reviewed construction across 1,829 cities and 256 counties. This is the widest-coverage dataset on the API and the one to reach for when Permits is too narrow geographically.
Each project is joined to the contractor entity graph and, where the filing names them, to the owner and design firm. That join is the product: TDLR publishes the registrations, but nobody else resolves the parties in them to stable, deduplicated identities you can track over time.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
county | string | optional | Texas county name, e.g. Bexar. Repeatable. |
city | string | optional | Texas city name, e.g. Richardson. Repeatable. |
trade | string | optional | NOT YET SUPPORTED — returns 400 with an explanatory error.details. The unified project record carries a project SECTOR, not a trade; trade is an attribute of the contractor, not of the project. Filter by project_type here, or resolve the contractor via /contractors/verify and read their trade from there. |
project_type | string | optional | commercial | residential | industrial | institutional | civil. |
filed_after | string (ISO 8601 date) | optional | Only projects filed on or after this date. |
filed_before | string (ISO 8601 date) | optional | Only projects filed on or before this date. |
min_value | integer | optional | Minimum estimated project value in whole US dollars. |
contractor_id | string | optional | Canonical entity id. Returns every project this contractor is attached to, in any role. |
q | string | optional | Full-text search across project name, scope, and address. |
limit | integer | optional | Results per page. 1–100, default 25. |
cursor | string | optional | Opaque cursor from meta.next_cursor. Pass it back verbatim to fetch the next page and stop when it comes back null. Treat the token as opaque: it is positional today, so rows inserted between two page fetches can shift a row across a page boundary. For a point-in-time-consistent sweep of a changing dataset, pin the window with the date filters (e.g. issued_before) rather than relying on the cursor alone. |
Response fields
| Field | Type | Description |
|---|---|---|
id | string | Stable Zabalist project id. |
registration_number | string | TDLR project registration number. |
name | string | Project name as filed. |
project_type | string | Normalized project class. |
scope | string | null | Scope of work as filed. |
estimated_value | integer | null | Estimated construction value in whole US dollars. |
filed_date | string | ISO 8601 date the registration was filed. |
status | string | registered | under_review | complete. |
address | object | street, city, state, zip, county. |
parties | array | Resolved contractor, owner, and design_firm entities with role and id. Contact fields are never included. |
source | object | Provenance: name, url, retrieved_at. |
curl -G "https://www.zabalist.com/api/public/v1/projects" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d county=Bexar \
-d project_type=commercial \
-d min_value=500000 \
-d filed_after=2026-01-01const params = new URLSearchParams({
county: 'Bexar',
project_type: 'commercial',
min_value: '500000',
filed_after: '2026-01-01',
});
const res = await fetch(
`https://www.zabalist.com/api/public/v1/projects?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const { data, meta } = await res.json();
// Page through every result.
let cursor = meta.next_cursor;
while (cursor) {
params.set('cursor', cursor);
const next = await fetch(
`https://www.zabalist.com/api/public/v1/projects?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
).then((r) => r.json());
data.push(...next.data);
cursor = next.meta.next_cursor;
}import os, requests
session = requests.Session()
session.headers["X-API-Key"] = os.environ["ZABALIST_API_KEY"]
params = {
"county": "Bexar",
"project_type": "commercial",
"min_value": 500_000,
"filed_after": "2026-01-01",
}
projects, cursor = [], None
while True:
if cursor:
params["cursor"] = cursor
body = session.get(
"https://www.zabalist.com/api/public/v1/projects",
params=params, timeout=30,
).json()
projects.extend(body["data"])
cursor = body["meta"]["next_cursor"]
if not cursor:
break
print(f"{len(projects)} Bexar County commercial projects"){
"data": [
{
"id": "prj_01JQ9RB6HT4YKD2XN8VMFZ3CQW",
"registration_number": "TABS2026-318447",
"name": "Kami Buffet & Grill — Tenant Finish-Out",
"project_type": "commercial",
"scope": "Interior finish-out, 6,400 sf restaurant, new kitchen hood and grease interceptor",
"estimated_value": 1240000,
"filed_date": "2026-02-19",
"status": "registered",
"address": {
"street": "1310 E Belt Line Rd",
"city": "Richardson",
"state": "TX",
"zip": "75081",
"county": "Dallas"
},
"parties": [
{
"role": "contractor",
"id": "ent_4TZ8PW2QNK6MJ3RB",
"name": "Alcorta Commercial Builders LP"
},
{
"role": "design_firm",
"id": "ent_9HM3XV7BQC2KTPFD",
"name": "Vandiver Architects Inc"
},
{ "role": "owner", "id": "ent_2FQ6NL8XRT4WMKBJ", "name": "Belt Line Retail Partners LLC" }
],
"source": {
"name": "Texas Department of Licensing and Regulation",
"url": "https://www.tdlr.texas.gov/tabs/",
"retrieved_at": "2026-08-07T09:16:41Z"
}
}
],
"meta": { "count": 25, "has_more": true, "next_cursor": "eyJ2IjoxLCJvIjoyNX0" }
}Ready to build?
Free tier: 500 calls/month, no card required.
Endpoint
Contractor Verify
/api/public/v1/contractors/verifyThe entity-resolution endpoint. Send whatever you have — a name off an invoice, a DBA, an address, a license number — and get back a canonical entity with TDLR registration status, trade, and dates, plus a confidence score and the alternates that were considered.
This is the endpoint prequalification, surety, lending, and marketplace-vetting workflows integrate against. License lookup on its own is a commodity; resolving "Chapman A/C", "CHAPMAN AIR COND & HTG", and "Chapman Air Conditioning and Heating, L.L.C." to one entity with 142,781 deduplicated peers and 888,179 work-history links behind it is not.
Calls to this endpoint count against your plan’s included Contractor Verify allowance. Every other endpoint counts only against your monthly call quota.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
name | string | optional | Business name, however messy. Required unless license or address is supplied. |
license | string | optional | A TDLR licence or registration number you already hold (format varies by trade, e.g. a TACLA/TECL-prefixed string). Matched against licence values recorded on the underlying government filings — this is NOT a lookup against the TDLR licence registry, and a miss does not mean the licence is invalid. When it matches it is the highest-precision way to resolve an entity; meta.notes flags the caveat on every licence-matched response. |
address | string | optional | Street address to disambiguate common names. |
city | string | optional | Texas city, used as a resolution tiebreaker. |
trade | string | optional | Expected trade, used as a resolution tiebreaker. |
min_confidence | number | optional | Reject matches below this confidence (0–1, default 0.6). Raise it for automated decisioning; lower it for human review queues. |
Response fields
| Field | Type | Description |
|---|---|---|
match | object | null | The highest-confidence resolved entity, or null when nothing cleared min_confidence. |
match.confidence | number | Deterministic name-overlap score, 0–1. NOT a calibrated probability — there is no labelled entity-resolution training set behind it. Use it to rank and to threshold, not as a likelihood in an automated decision. |
match.entity | object | id (UUID), slug, name, canonical_name, types[], business_type, city, county, state, zip, name_variants_merged. Store entity.id, not the name. |
match.matched_on | string | Which input produced the match: name or license. |
match.matched_value | string | The exact input value that matched. |
match.licenses | array | ALWAYS an empty array. Zabalist holds TDLR project registrations, not the TDLR licence registry, so there is no licence status, number, or expiry to return. Do not build a licence check on this field — read match.tdlr and send the user to tdlr.registry_url. |
match.tdlr | object | Registration facts derived from TDLR project filings — registered_projects, last_registered_project_date, activity_status (active | dormant | unknown, derived from filing recency and explicitly not a licence state), license_verified (always false), license_note, registry_url. |
match.work_history | object | project_count, verified_project_count, active_projects, total_value, first_activity_date, last_activity_date, counties_active, counties[], top_counties[], by_source{}, by_role{}. |
match.govcon | object | Federal enrichment: sam_registered, uei, certifications[] (SBA set-asides, e.g. HUB/8(a)), primary_naics, naics_codes[]. |
match.risk_flags | string[] | Derived flags: dormant, no_verified_work, name_churn. Empty array means none fired. |
match.provenance | string[] | Government source tokens backing this record. |
match.history_url | string | Ready-made path for the Contractor History endpoint. |
alternates | array | Other candidates considered, with id, name, and confidence. Use to build a human review queue. |
usage | object | verify_used and verify_included for the current UTC calendar month. |
data / meta | object | Every response also carries the standard envelope. match is data[0]; meta.notes carries the licence caveat on every response. |
curl -G "https://www.zabalist.com/api/public/v1/contractors/verify" \
-H "X-API-Key: $ZABALIST_API_KEY" \
--data-urlencode "name=Chapman A/C and Heating" \
--data-urlencode "city=Austin" \
-d trade=hvac \
-d min_confidence=0.75const params = new URLSearchParams({
name: 'Chapman A/C and Heating',
city: 'Austin',
trade: 'hvac',
min_confidence: '0.75',
});
const res = await fetch(
`https://www.zabalist.com/api/public/v1/contractors/verify?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const { match, alternates } = await res.json();
if (!match) {
// Nothing cleared min_confidence — route to manual review.
console.warn('No confident match', alternates.slice(0, 3));
} else {
// match.licenses is ALWAYS []. Zabalist resolves identity and documents
// work history; it does not hold the TDLR licence registry, so there is
// no licence status here to gate on. Confirm licensure at the registry.
console.log('Resolved:', match.entity.name, match.entity.id);
console.log('TDLR registrations:', match.tdlr.registered_projects);
console.log('Activity:', match.tdlr.activity_status);
console.log('Verified work:', match.work_history.verified_project_count);
if (match.risk_flags.length) {
console.warn('Risk flags:', match.risk_flags);
}
// license_verified is always false — surface the registry link to a human.
console.log('Confirm licence at:', match.tdlr.registry_url);
}import os, requests
res = requests.get(
"https://www.zabalist.com/api/public/v1/contractors/verify",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
params={
"name": "Chapman A/C and Heating",
"city": "Austin",
"trade": "hvac",
"min_confidence": 0.75,
},
timeout=30,
)
body = res.json()
match = body["match"]
# match["licenses"] is always [] — Zabalist holds TDLR project registrations,
# not the TDLR licence registry. Prequal on documented work history and send
# the licence question to the registry.
if match and not match["risk_flags"]:
print(match["entity"]["name"], "->", match["entity"]["id"])
print("verified projects:", match["work_history"]["verified_project_count"])
print("tdlr registrations:", match["tdlr"]["registered_projects"])
print("confirm licence at:", match["tdlr"]["registry_url"]){
"match": {
"confidence": 0.94,
"risk_flags": [],
"licenses": [],
"entity": {
"id": "7f3c1a94-2d68-4e51-9b0a-6c8d5e2f4a13",
"slug": "chapman-air-conditioning-heating-llc",
"name": "Chapman Air Conditioning & Heating LLC",
"canonical_name": "CHAPMAN AIR CONDITIONING & HEATING LLC",
"types": ["contractor"],
"business_type": "mechanical",
"city": "Austin",
"county": "Travis",
"state": "TX",
"zip": "78758",
"name_variants_merged": 3
},
"matched_on": "name",
"matched_value": "Chapman A/C and Heating",
"tdlr": {
"registered_projects": 47,
"last_registered_project_date": "2026-06-11",
"activity_status": "active",
"license_verified": false,
"license_note": "Zabalist does not hold the TDLR licence registry. These are registration facts derived from TDLR project filings. Verify licence status at the registry URL.",
"registry_url": "https://www.tdlr.texas.gov/LicenseSearch/"
},
"work_history": {
"project_count": 412,
"verified_project_count": 388,
"active_projects": 11,
"total_value": 24875000,
"first_activity_date": "2011-08-15",
"last_activity_date": "2026-07-28",
"counties_active": 4,
"counties": ["Travis", "Williamson", "Hays", "Bastrop"],
"top_counties": [
{ "county": "Travis", "project_count": 301, "last_project_date": "2026-07-28" },
{ "county": "Williamson", "project_count": 74, "last_project_date": "2026-05-02" }
],
"by_source": { "permit": 1877, "site_plan": 12, "subdivision": 0, "tdlr": 47 },
"by_role": { "owner": 0, "design_firm": 0, "contractor": 412, "developer": 0 }
},
"govcon": {
"sam_registered": false,
"uei": null,
"certifications": [],
"primary_naics": null,
"naics_codes": []
},
"provenance": ["city_of_austin", "tdlr"],
"history_url": "/api/public/v1/contractors/7f3c1a94-2d68-4e51-9b0a-6c8d5e2f4a13/history"
},
"alternates": [
{ "id": "b21e7d05-9a34-4c77-8e16-3f9b0d7c5218", "name": "Chapman Mechanical Services Inc", "confidence": 0.41 }
],
"usage": { "verify_used": 118, "verify_included": 500 },
"meta": {
"count": 1,
"limit": 1,
"offset": 0,
"notes": [
"confidence is a deterministic name-overlap score, not a calibrated probability. Matches are ordered by documented project count.",
"licenses[] is always empty: Zabalist holds TDLR project registrations, not the TDLR licence registry. Use tdlr.registry_url."
]
}
}{
"error": {
"code": "quota_exceeded",
"message": "Monthly quota of 500 calls exhausted for the Free plan. Resets 2026-09-01T00:00:00Z."
}
}Ready to build?
Free tier: 500 calls/month, no card required.
Endpoint
Contractor History
/api/public/v1/contractors/{id}/historyEverything the entity graph knows about one contractor’s work, drawn from 888,179 work-history links. Returns a rolled-up summary plus the individual records, filterable by date, county, and record type.
Pair it with Contractor Verify: verify resolves who they are, history answers whether they have actually done the work. A prequalification flow typically calls verify once at onboarding and history on a schedule thereafter.
The {id} path segment is the canonical entity id returned by Contractor Verify, Permits, or Projects. Raw names are not accepted here by design — resolve first, then look up.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (path) | required | Canonical entity id, e.g. ent_6VN2QK9WPT3XMRBH. |
record_type | string | optional | Filter to project, permit, bid, or award. Repeatable. Omit for all four. |
county | string | optional | Restrict to one Texas county. Repeatable. |
since | string (ISO 8601 date) | optional | Only records on or after this date. |
until | string (ISO 8601 date) | optional | Only records on or before this date. |
include | string | optional | Comma-separated expansions: summary, records, counterparties. Default summary,records. |
limit | integer | optional | Results per page. 1–100, default 25. |
cursor | string | optional | Opaque cursor from meta.next_cursor. Pass it back verbatim to fetch the next page and stop when it comes back null. Treat the token as opaque: it is positional today, so rows inserted between two page fetches can shift a row across a page boundary. For a point-in-time-consistent sweep of a changing dataset, pin the window with the date filters (e.g. issued_before) rather than relying on the cursor alone. |
Response fields
| Field | Type | Description |
|---|---|---|
contractor | object | id and name of the entity the history belongs to. |
summary | object | Rolled-up totals across the filtered window. |
summary.total_records | integer | Records matching the filters. |
summary.total_value | integer | null | Sum of known values in whole US dollars. Null when no record carried a value. |
summary.first_activity | string | ISO 8601 date of the earliest record. |
summary.last_activity | string | ISO 8601 date of the most recent record. The single best staleness signal. |
summary.by_type | object | Counts keyed by projects, permits, bids, awards. |
summary.counties | string[] | Texas counties with at least one record. |
summary.finaled_rate | number | null | Share of permits reaching finaled status, 0–1. Null when no permits are in scope. |
data | array | Individual records: type, id, date, title, value, address, role, counterparty. |
meta | object | Standard pagination envelope over data. |
curl -G "https://www.zabalist.com/api/public/v1/contractors/ent_6VN2QK9WPT3XMRBH/history" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d record_type=permit \
-d since=2024-01-01 \
-d include=summary,records \
-d limit=50const id = 'ent_6VN2QK9WPT3XMRBH';
const params = new URLSearchParams({
record_type: 'permit',
since: '2024-01-01',
include: 'summary,records',
limit: '50',
});
const res = await fetch(
`https://www.zabalist.com/api/public/v1/contractors/${id}/history?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const { summary, data } = await res.json();
// A prequal signal: active recently, and finishes what it starts.
const daysSinceWork =
(Date.now() - Date.parse(summary.last_activity)) / 86_400_000;
const qualifies = daysSinceWork < 180 && (summary.finaled_rate ?? 0) > 0.8;import os, requests
from datetime import date
entity_id = "ent_6VN2QK9WPT3XMRBH"
res = requests.get(
f"https://www.zabalist.com/api/public/v1/contractors/{entity_id}/history",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
params={"record_type": "permit", "since": "2024-01-01", "limit": 50},
timeout=30,
)
body = res.json()
summary = body["summary"]
print(f"{summary['total_records']} permits across {len(summary['counties'])} counties")
print(f"last active {summary['last_activity']}, finaled rate {summary['finaled_rate']:.0%}"){
"contractor": {
"id": "ent_6VN2QK9WPT3XMRBH",
"name": "Chapman Air Conditioning & Heating LLC"
},
"summary": {
"total_records": 1877,
"total_value": 41287400,
"first_activity": "2011-08-15",
"last_activity": "2026-07-28",
"by_type": { "projects": 412, "permits": 1877, "bids": 23, "awards": 6 },
"counties": ["Travis", "Williamson", "Hays", "Bastrop"],
"finaled_rate": 0.91
},
"data": [
{
"type": "permit",
"id": "prm_01JR2XB8MK6WTND4QVZ7FH3PYC",
"date": "2026-07-28",
"title": "Mechanical — replace 5-ton split system",
"value": 14800,
"role": "contractor",
"address": {
"street": "4501 Spicewood Springs Rd",
"city": "Austin",
"state": "TX",
"zip": "78759",
"county": "Travis"
},
"counterparty": null
}
],
"meta": { "count": 50, "has_more": true, "next_cursor": "eyJ2IjoxLCJvIjo1MH0" }
}Ready to build?
Free tier: 500 calls/month, no card required.
Endpoint
Bids
/api/public/v1/bidsPublic procurement from the Texas Electronic State Business Daily and federal sources, covering 24,397 solicitations across all 254 Texas counties with 41,423 status-history transitions behind them.
The differentiator is not the bid list — it is the overlay. Every solicitation is joined to the contractor graph and to SBA 8(a) / HUBZone / WOSB certification data, which is what lets you answer "who is likely bidding against me" and "which certified sub can fill this set-aside". Those are the two questions the incumbent bid-notification services do not answer at any price.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
county | string | optional | Texas county name. Repeatable. |
agency | string | optional | Issuing agency name or agency code. |
nigp_code | string | optional | NIGP commodity class code, e.g. 909 (construction). Repeatable. |
status | string | optional | open | closed | awarded | cancelled. Default open. |
due_after | string (ISO 8601 date) | optional | Only solicitations closing on or after this date. |
due_before | string (ISO 8601 date) | optional | Only solicitations closing on or before this date. |
set_aside | string | optional | NOT YET SUPPORTED — returns 400 with an explanatory error.details. Set-aside and certification data lives on the CONTRACTOR (entity_groups.sba_certifications), not on the solicitation. Read it from /contractors/verify → match.govcon.certifications. |
min_value | integer | optional | Minimum estimated contract value in whole US dollars. |
q | string | optional | Full-text search across title and description. |
limit | integer | optional | Results per page. 1–100, default 25. |
cursor | string | optional | Opaque cursor from meta.next_cursor. Pass it back verbatim to fetch the next page and stop when it comes back null. Treat the token as opaque: it is positional today, so rows inserted between two page fetches can shift a row across a page boundary. For a point-in-time-consistent sweep of a changing dataset, pin the window with the date filters (e.g. issued_before) rather than relying on the cursor alone. |
Response fields
| Field | Type | Description |
|---|---|---|
id | string | Stable Zabalist solicitation id. |
solicitation_number | string | Issuing agency’s solicitation number, verbatim. |
title | string | Solicitation title as published. |
agency | object | id, name, and type of the issuing agency. |
status | string | open | closed | awarded | cancelled. |
posted_date | string | ISO 8601 date the solicitation was posted. |
due_date | string | null | ISO 8601 timestamp responses are due. Null when the agency published none. |
nigp_codes | string[] | NIGP commodity class codes attached to the solicitation. |
counties | string[] | Texas counties in scope of the work. |
estimated_value | integer | null | Estimated contract value in whole US dollars, when published. |
award | object | null | Resolved winning entity, amount, and date. Null until awarded. |
documents | array | name, url, and content_type for published attachments. |
source | object | Provenance: name, url, retrieved_at. |
curl -G "https://www.zabalist.com/api/public/v1/bids" \
-H "X-API-Key: $ZABALIST_API_KEY" \
-d county=Bexar \
-d nigp_code=909 \
-d status=open \
-d due_after=2026-08-08const params = new URLSearchParams({
county: 'Bexar',
nigp_code: '909',
status: 'open',
due_after: new Date().toISOString().slice(0, 10),
});
const res = await fetch(
`https://www.zabalist.com/api/public/v1/bids?${params}`,
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const { data } = await res.json();
const closingSoon = data.filter((bid) => {
const days = (Date.parse(bid.due_date) - Date.now()) / 86_400_000;
return days > 0 && days <= 14;
});import os, requests
from datetime import date
res = requests.get(
"https://www.zabalist.com/api/public/v1/bids",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
params={
"county": "Bexar",
"nigp_code": "909",
"status": "open",
"due_after": date.today().isoformat(),
},
timeout=30,
)
for bid in res.json()["data"]:
print(bid["due_date"], bid["agency"]["name"], bid["title"]){
"data": [
{
"id": "bid_01JRB4TN7QK2WMXD9VPH6FZ3CY",
"solicitation_number": "ESBD-2026-704318",
"title": "FM 1518 Reconstruction — Drainage and Paving, Bexar County",
"agency": {
"id": "agy_TXDOT",
"name": "Texas Department of Transportation",
"type": "state"
},
"status": "open",
"posted_date": "2026-07-22",
"due_date": "2026-09-04T14:00:00-05:00",
"nigp_codes": ["909", "913"],
"counties": ["Bexar"],
"estimated_value": 8750000,
"award": null,
"documents": [
{
"name": "Invitation for Bids",
"url": "https://www.txsmartbuy.gov/esbd/ESBD-2026-704318/ifb.pdf",
"content_type": "application/pdf"
}
],
"source": {
"name": "Texas Electronic State Business Daily",
"url": "https://www.txsmartbuy.gov/esbd",
"retrieved_at": "2026-08-08T06:02:10Z"
}
}
],
"meta": { "count": 25, "has_more": true, "next_cursor": "eyJ2IjoxLCJvIjoyNX0" }
}Ready to build?
Free tier: 500 calls/month, no card required.
Endpoint
Usage
/api/public/v1/usageRead your own meter. Returns consumption for the current Stripe billing period for the key making the request, plus a per-endpoint breakdown.
This endpoint is free: it never counts against your monthly call quota and never against your Contractor Verify allowance. Poll it as often as you like — the intended pattern is a check before a large batch job, and an alert when percent_used crosses a threshold you pick.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
period | string | optional | current (default) or previous. Only the two most recent billing periods are addressable. |
breakdown | boolean | optional | Include the per-endpoint call breakdown. Default true. |
Response fields
| Field | Type | Description |
|---|---|---|
plan | object | tier, name, and interval of the active plan. |
period | object | start and end of the billing period, ISO 8601. |
calls | object | used, included, remaining, percent_used. |
verify | object | used, included, overage_units, overage_cost_usd at $0.15 per lookup. |
rate_limit | object | rpm for the plan and the current window’s remaining allowance. |
breakdown | array | null | endpoint and calls per endpoint. Null when breakdown=false. |
key | object | prefix, name, and created_at of the key that made the request. The secret is never returned. |
curl "https://www.zabalist.com/api/public/v1/usage" \
-H "X-API-Key: $ZABALIST_API_KEY"const res = await fetch(
'https://www.zabalist.com/api/public/v1/usage',
{ headers: { 'X-API-Key': process.env.ZABALIST_API_KEY } }
);
const usage = await res.json();
if (usage.calls.percent_used > 0.8) {
console.warn(
`${usage.calls.used}/${usage.calls.included} calls used ` +
`(${usage.plan.name}). Resets ${usage.period.end}.`
);
}import os, requests
usage = requests.get(
"https://www.zabalist.com/api/public/v1/usage",
headers={"X-API-Key": os.environ["ZABALIST_API_KEY"]},
timeout=30,
).json()
remaining = usage["calls"]["remaining"]
if remaining < 5_000:
raise SystemExit(f"Only {remaining} calls left this period — aborting batch"){
"plan": { "tier": "growth", "name": "Growth", "interval": "month" },
"period": { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z" },
"calls": {
"used": 38412,
"included": 100000,
"remaining": 61588,
"percent_used": 0.384
},
"verify": {
"used": 5310,
"included": 5000,
"overage_units": 310,
"overage_cost_usd": 46.50
},
"rate_limit": { "rpm": 300, "remaining": 297 },
"breakdown": [
{ "endpoint": "/permits", "calls": 21044 },
{ "endpoint": "/projects", "calls": 11058 },
{ "endpoint": "/contractors/verify", "calls": 5310 },
{ "endpoint": "/bids", "calls": 1000 }
],
"key": {
"prefix": "zbl_live_7Kq2xM",
"name": "production-worker",
"created_at": "2026-05-14T18:22:03Z"
}
}Ready to build?
Free tier: 500 calls/month, no card required.
Reference
Changelog
The API is versioned in the path. Additive changes — new endpoints, new optional parameters, new response fields — ship into v1 without notice, so write clients that ignore fields they do not recognise. Anything that could break a working integration ships as v2, and v1 stays available for at least twelve months after v2 is generally available.
- 2026-08-08v1
Public beta
Permits, Projects, Contractor Verify, Contractor History, Bids, and Usage. Cursor pagination, per-key rate limiting, and the Free / Developer / Growth / Scale plan ladder.
- Plannedv1
Statewide permit coverage
DFW, Houston, and San Antonio permit ingestion widens the Permits endpoint beyond City of Austin. Additive — no client change required, and existing filters keep working.
- Plannedv1
Webhooks and MCP server
Subscribe a saved query to a webhook instead of polling, and use the same read-only endpoints from an AI agent through the Model Context Protocol.
Questions, or need something that is not here?
Email [email protected]. We answer within one business day, and we would rather hear about a missing filter than watch you work around it.
Ready to build?
Free tier: 500 calls/month, no card required.