← Back to lookup

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 outcome discriminator 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.ok and 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

outcomeHTTPWhen
found200Parcel + tax record resolved. `data` is fully populated.
paywalled402Source platform requires payment to view the bill. `data` carries the parcel; `data.tax.bills` may be empty.
not_found404Query parsed cleanly but no parcel matched.
input_invalid400Bad `q` — couldn't parse, no recognized county, or missing parameter.
platform_error500Upstream 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.

codeMeaning
INPUT_MISSING_QNo `q` parameter supplied.
INPUT_PARSE_FAILURE`q` couldn't be parsed (e.g., unrecognized PIN format).
MUNICIPALITY_NOT_SUPPORTEDQuery parsed, but no covered county / municipality matched.
PROPERTY_NOT_FOUNDQuery targeted a covered county, but no parcel matched the address/PIN.
PAYWALLEDSource platform refuses to render bill without payment. Accompanies 402.
TAX_PLATFORM_UNREACHABLEUpstream (BS&A, BAS, VGSI, etc.) timed out or returned an unexpected error.
RATE_LIMIT_EXCEEDEDPer-IP rate limit hit. Accompanies 429 + `Retry-After`.
AUTH_MISSING_KEYNo API key supplied. Accompanies 401.
AUTH_INVALID_KEYAPI key did not match. Accompanies 401.
INTERNAL_ERRORUnhandled 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 / outstanding totals, a delinquency flag, free-text payment status, and a lineItems[] breakdown — every taxing authority with its millageRate and per-authority taxableValue where the source breaks it out.
data.tax.summary
Most-recent-year roll-up: totalBilled, totalPaid, delinquentBalance, and a delinquent flag.
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 →