API reference
TaxStatus API
A property-tax lookup endpoint plus a thumbs-up/down feedback hook, built around HTTP status discipline and a discriminated response envelope. For commercial use, programmatic integration, and higher rate limits, see TaxStatus for business.
At a glance
- Tagged envelope with an
outcomediscriminator on every processed request — clients write one switch, not nine null checks. - HTTP status semantics match the outcome — 200/400/402/404/500 — so
response.okand standard retry/monitoring logic do the right thing. - Machine-readable error codes on every error entry (
code+message). - Stable event correlation via
meta.eventId— attach it to feedback or quote it in support tickets.
Authentication
Lookup requests require an API key. Send it as either header:
Authorization: Bearer <your-key>
# — or —
X-API-Key: <your-key>Request a key at info@taxstatus.org. Feedback (POST /api/feedback) is unauthenticated.
Rate limits
5 requests per minute per IP, shared across/api/lookup and /api/feedback. Commercial pilots get higher, dedicated limits.
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. On exhaustion you get 429 with a Retry-After header and a RATE_LIMIT_EXCEEDED error code.
Endpoint: GET /api/lookup
Takes a street address or PIN via the q query parameter and returns the parcel + tax record.
GET https://www.taxstatus.org/api/lookup?q=<address or PIN>Example:
curl -H "Authorization: Bearer <key>" \
"https://www.taxstatus.org/api/lookup?q=504 Burgenstock Dr, Lansing, MI"Response envelope
Every request that gets past auth + rate-limit returns this shape. The outcome discriminator tells you what happened; the HTTP status mirrors it.
{
"outcome": "found" | "not_found" | "input_invalid" | "paywalled" | "platform_error",
"data": { /* parcel, tax, municipality, ... */ } | null,
"errors": [{ "code": "...", "message": "..." }],
"warnings": [ "..." ],
"meta": { "eventId": "uuid", "fetchedAt": "ISO 8601" }
}Auth / rate-limit rejections (401, 429) are returned before the request is processed and use a slim shape — errors[] + meta.fetchedAt, no outcome or data.
Outcomes → HTTP status
| outcome | HTTP | When |
|---|---|---|
found | 200 | Parcel + tax record resolved. `data` is fully populated. |
paywalled | 402 | Source platform requires payment to view the bill. `data` carries the parcel; `data.tax.bills` may be empty. |
not_found | 404 | Query parsed cleanly but no parcel matched. |
input_invalid | 400 | Bad `q` — couldn't parse, no recognized county, or missing parameter. |
platform_error | 500 | Upstream platform (BS&A, BAS, VGSI, or county GIS) unreachable or returned an unexpected error. |
Error codes
Each entry in errors[] has a stable, machine-readable code and a human message. Branch on code, not message.
| code | Meaning |
|---|---|
INPUT_MISSING_Q | No `q` parameter supplied. |
INPUT_PARSE_FAILURE | `q` couldn't be parsed (e.g., unrecognized PIN format). |
MUNICIPALITY_NOT_SUPPORTED | Query parsed, but no covered county / municipality matched. |
PROPERTY_NOT_FOUND | Query targeted a covered county, but no parcel matched the address/PIN. |
PAYWALLED | Source platform refuses to render bill without payment. Accompanies 402. |
TAX_PLATFORM_UNREACHABLE | Upstream (BS&A, BAS, VGSI, etc.) timed out or returned an unexpected error. |
RATE_LIMIT_EXCEEDED | Per-IP rate limit hit. Accompanies 429 + `Retry-After`. |
AUTH_MISSING_KEY | No API key supplied. Accompanies 401. |
AUTH_INVALID_KEY | API key did not match. Accompanies 401. |
INTERNAL_ERROR | Unhandled server error. Accompanies 500. |
New codes may be added over time; existing codes never change meaning. Treat unknown code values asINTERNAL_ERROR.
Example: success (outcome=found, 200 OK)
{
"outcome": "found",
"data": {
"query": {
"raw": "504 Burgenstock Dr, Lansing, MI",
"kind": "address",
"normalized": "504 burgenstock dr, lansing, mi"
},
"geocoded": { "lat": 42.69, "lon": -84.59, "match": "504 Burgenstock Dr, ..." },
"municipality": {
"state": "mi",
"county": "eaton",
"name": "Delta Charter Township",
"type": "township",
"taxPlatformId": "418",
"lookupUrl": "https://bsaonline.com/?uid=418",
"treasurerUrl": null,
"notes": null
},
"parcel": {
"pin": "040-080-750-160-00",
"ownerName": "DOE JOHN",
"ownerMailingAddress": "504 BURGENSTOCK DR, LANSING MI 48917",
"propertyAddress": "504 BURGENSTOCK DR",
"propertyClass": "401 RESIDENTIAL-IMPROVED",
"schoolDistrict": "WAVERLY COMMUNITY SCHOOLS",
"assessedValue": 142300,
"taxableValue": 118650,
"sevValue": 142300,
"cappedValue": 118650,
"principalResidenceExemption": 100,
"source": "arcgis-eaton"
},
"taxPlatformOverrides": {},
"tax": {
"sourceName": "BS&A Online",
"externalUrl": "https://bsaonline.com/...",
"summary": {
"year": 2025,
"totalBilled": 4218.42,
"totalPaid": 4218.42,
"delinquentBalance": 0,
"delinquent": false
},
"bills": [
{
"label": "2025 Summer", "year": 2025, "cycle": "Summer",
"billed": 2871.06, "paid": 2871.06, "outstanding": 0,
"delinquent": false, "paymentStatus": "Paid",
"lineItems": [
{ "label": "STATE EDUCATION", "amount": 712.41, "millageRate": 6, "taxableValue": 118650 },
{ "label": "WAVERLY SCHOOLS OPERATING", "amount": 2135.70, "millageRate": 18, "taxableValue": 118650 }
]
}
],
"importantMessages": [],
"assessingDetails": { "sections": [ /* ... */ ] },
"specialAssessments": [],
"errors": [], "warnings": [], "paywalled": false
},
"paywalled": false,
"externalUrl": "https://bsaonline.com/..."
},
"errors": [],
"warnings": [],
"meta": {
"eventId": "a0a4c652-0ed2-4117-8ee9-e815ee89d93f",
"fetchedAt": "2026-06-13T18:02:11.418Z"
}
}Example: not found (outcome=not_found, 404)
{
"outcome": "not_found",
"data": {
"query": { "raw": "999 Nowhere St", "kind": "address", "normalized": "..." },
"geocoded": null,
"municipality": null,
"parcel": null,
"taxPlatformOverrides": {},
"tax": null,
"paywalled": false,
"externalUrl": null
},
"errors": [
{ "code": "PROPERTY_NOT_FOUND", "message": "No parcel matched the supplied query." }
],
"warnings": [],
"meta": { "eventId": "...", "fetchedAt": "..." }
}Example: auth rejection (401)
{
"errors": [
{ "code": "AUTH_MISSING_KEY", "message": "No API key supplied. Send `Authorization: Bearer <key>` or `X-API-Key: <key>`." }
],
"meta": { "fetchedAt": "2026-06-13T18:02:11.418Z" }
}Key objects within data
- data.parcel
- The parcel record — owner, addresses, valuations (assessed / taxable / SEV / capped), property class, school district, PRE percentage, and the canonical
pin. - data.municipality
- Which local unit owns the parcel — name, type (township / city / village / town), county, state, and the tax-platform identifier.
- data.tax.bills[]
- One entry per bill cycle (e.g. 2025 Summer, 2025 Winter). Each bill carries
billed/paid/outstandingtotals, a delinquency flag, free-text payment status, and alineItems[]breakdown — every taxing authority with itsmillageRateand per-authoritytaxableValuewhere the source breaks it out. - data.tax.summary
- Most-recent-year roll-up:
totalBilled,totalPaid,delinquentBalance, and adelinquentflag. - data.tax.specialAssessments[]
- Named special assessments with totals, balances, installment schedules, and source description.
- data.tax.assessingDetails
- Full structured data from the source’s Property Information / Assessing view — prior-year values, land + building info, sale history, legal description. Generic sections (
title,labelValues,tables) so every municipality flows through verbatim. - data.tax.importantMessages[]
- Alerts the municipality publishes on its property page — typically pointers like “call X for special assessments.” Surfaced verbatim because that’s how a user reaches data we can’t fetch programmatically.
- data.externalUrl
- Direct link to the official municipal record. Verify against it for legal, financial, or closing decisions.
- warnings[]
- Non-fatal context (e.g., “upstream returned partial data”). The outcome is still authoritative — warnings don’t change the discriminator.
- meta.eventId
- UUID for this request. Pair with the feedback endpoint or quote it in support tickets to look it up in /admin/events.
Endpoint: POST /api/feedback
Thumbs-up / down on a previous lookup. Unauthenticated; same 5/min/IP rate-limit bucket.
POST https://www.taxstatus.org/api/feedback
Content-Type: application/json
{
"eventId": "<meta.eventId from /api/lookup>", // optional
"rating": "up" | "down", // required
"comment": "..." // optional, max 2000 chars
}If eventId is included, the feedback is joined to that event and rolls up into municipality_quality.thumbs_up_count / thumbs_down_count for the muni that event hit. Omitting it records the feedback unattributed.
Returns 200 with { "ok": true } on success, 400 for a malformed body, 429 on rate limit, 500 on DB failure.
Coverage (18 counties)
Live coverage is on the about page and auto-updates as adapters ship. For commercial customers, coverage follows demand — tell us which jurisdictions you need.
Ready to integrate?
Request a key or talk through a pilot at info@taxstatus.org.
See the commercial offering →