error reference LIVE
Every error the shinnslist platform actually returns — exact response JSON, what causes it, and how to fix it. Copied from the deployed functions code, not paraphrased. Anything still planned is marked DRAFT.
functions/_middleware.js, functions/lib/api-keys.js, functions/lib/validate.js, functions/lib/v1.js, functions/lib/mcp.js, functions/api/intel.js, functions/api/parcel/[id].js, functions/api/goals.js). The error string values are quoted verbatim from the code — they are the values your client should match on. No status code, header, or body field is documented here that the code does not send.two error families
The platform does not use one error convention. It uses two. (The old playground password gate was removed on 2026-10-10 — the site is public, no 401 wall.)
| family | shape | where it appears |
|---|---|---|
REST {ok:false} | {"ok": false, "error": "<string>", …} | all /api/* REST endpoints today |
| MCP JSON-RPC | {"jsonrpc":"2.0","id":…,"error":{"code":…,"message":"…","data":…}} | /api/mcp tool-call errors |
The v1 family ({"request_id":…, "ok": false, "error": "<code>", "message": …}) is defined in functions/lib/v1.js but no shipped endpoint exercises it yet — see the DRAFT section below.
401 — authentication
One 401 remains:
invalid API key (functions/lib/api-keys.js) — JSON, from the key resolver:
HTTP/1.1 401 Unauthorized
{
"ok": false,
"error": "invalid api key",
"hint": "get a free key: POST /api/keys"
}
- caused by: a missing, mistyped, revoked, or otherwise unresolvable key on
?key=, thex-api-keyheader, orAuthorization: Bearer. - fix: issue a fresh free key via
POST /api/keys(see the api keys page).
429 — rate limits and quotas
The full throttle surface is documented on the rate limits page. The exact 429 shapes, side by side:
HTTP/1.1 429 Too Many Requests
Retry-After: 34
{
"error": "rate_limited",
"message": "Too many requests — 100 per minute per IP.",
"retry_after_seconds": 34,
"ip": "203.0.113.9"
}
Edge throttle (layer 1, functions/_middleware.js): no ok field, capital-R Retry-After header, plus the client IP echoed back.
HTTP/1.1 429 Too Many Requests
retry-after: 12
{
"ok": false,
"error": "rate_limit_exceeded",
"retry_after_seconds": 12
}
Per-endpoint token bucket (layer 2, functions/lib/api-keys.js): ok:false, lowercase retry-after header. The header case differs between layers — the code sends them exactly as shown.
HTTP/1.1 429 Too Many Requests
retry-after: 12
{
"ok": false,
"error": "issuance_rate_limit_exceeded",
"retry_after_seconds": 12
}
POST /api/keys issuance bucket (3 per hour per IP) — same shape, different error string so your client can tell it apart.
HTTP/1.1 429 Too Many Requests
retry-after: 41803
{
"ok": false,
"error": "daily_quota_exceeded",
"reason": "daily_quota_exceeded",
"quota": { "limit": 100, "resets_at": "2026-09-30T00:00:00.000Z" },
"hint": "quota resets at 00:00 UTC; paid tiers raise the cap"
}
Daily quota (freemium gate): also a 429, not a 403. The retry-after counts down to midnight UTC. A quota 429 means your day's calls are spent; a burst 429 means you fired too fast — retry in retry_after_seconds either way.
403 — not entitled
HTTP/1.1 403 Forbidden
{
"ok": false,
"error": "signal_not_entitled",
"reason": "signal_not_entitled",
"denied": [
{ "signal_id": "well_depth_drill_cost", "reason": "signal not in plan entitlements" }
],
"hint": "this signal requires a higher tier"
}
- caused by: the pipeline returned a signal your key's plan does not entitle (
functions/api/intel.js→entitlementDeniedResponse). - fix: drop that signal from the request or raise the key's tier. The
deniedarray names exactly which signals were refused.
400 — validation errors
Every public parameter is validated before it touches a query, URL, or downstream fetch (functions/lib/validate.js). All 400s share one shape — the error string is exact:
HTTP/1.1 400 Bad Request
{ "ok": false, "error": "<exact message>" }
exact error string | when |
|---|---|
q required | /api/intel called without ?q= |
q too long: max 2000 characters | query over the 2000-char cap |
q contains invalid characters | control characters / null bytes in ?q= |
zip required: pass a 5-digit ZIP (ZIP+4 accepted) | zip-gated endpoint without ?zip= |
zip too long: max 10 characters | ?zip= over 10 chars |
invalid zip: expected 5 digits (e.g. 22903) or ZIP+4 (e.g. 22903-1234) | non-ZIP ?zip= |
parcel id required (format: namespace:layer:objectid, e.g. nhd:point:123456) | /api/parcel/:id with an empty id |
parcel id too long: max 128 characters | parcel id over 128 chars |
malformed parcel id: expected namespace:layer:objectid (e.g. nhd:point:123456) — lowercase namespace and layer, letters/digits/dash/underscore only | parcel id not matching namespace:layer:objectid |
unknown clue1 / unknown clue2 | /api/properties with a clue id not in the allowlist — response also carries valid_clues |
missing ?goal= | /api/goals without a goal description |
place_not_found | /api/goals?place=… geocoding failed — message appends "Try a Virginia town or zip." |
place_outside_virginia | ?place=… resolved outside Virginia — this release hunts Virginia only |
Fix for all of them: the error string tells you exactly what to change. ?zip= accepts ZIP+4 (normalized to the 5-digit base); parcel ids are namespace:layer:objectid, lowercase, e.g. nhd:point:123456.
404 — parcel lookups
GET /api/parcel/:id maps a well-formed id through the resolver. All three failure modes return 404 with the message in the error field (not a fixed code — quote the message, don't switch on a code):
HTTP/1.1 404 Not Found
{ "ok": false, "error": "Parcel id xyz:query:123 is well formed, but the xyz:query namespace is not in shinnslist's resolvable catalog. Resolvable namespaces: nhd:point, …." }
- unknown namespace: format was right, the namespace isn't in the resolvable catalog — the message lists what is.
- not found: well-formed id, no record under that layer/objectid.
- outside coverage: feature is outside shinnslist's Virginia-only coverage — reported as an honest 404, never a guess.
A successful lookup returns { "ok": true, "parcel": … } with a freshness timestamp.
500 — pipeline failure
HTTP/1.1 500 Internal Server Error
{
"ok": false,
"error": "pipeline failure: <the exception's message>",
"pipeline_version": "2026-09-29"
}
- caused by: the intel pipeline threw while building the response (
functions/api/intel.js). It is logged with theinternalerror class, so spikes show up in the/api/healtherror readout. - fix: retry — the failure is downstream (a source fetch or adapter), not your request. Quote
pipeline_versionif you report it.
502 — upstream source unreachable
HTTP/1.1 502 Bad Gateway
{ "ok": false, "error": "Parcel source unreachable (Fairfax County GIS): <the fetch error>" }
- caused by: the county GIS / USGS source behind the endpoint didn't answer (
functions/lib/parcels.jssource_unavailable). Honest, never faked — a dead source surfaces as a 502, not stale data. - fix: wait and retry; this is a source outage, not a request error.
degraded ≠ error: the unavailable contract
A throwing adapter never becomes a 500. It becomes a 200 with status: "degraded" and an unavailable array naming the reason (functions/lib/mcp.js / functions/lib/parcels.js):
HTTP/1.1 200 OK
{
"signal_id": "well_depth_drill_cost",
"status": "degraded",
"result": null,
"evidence": [],
"unavailable": ["adapter error: county GIS timed out"],
"confidence": "low",
"note": "Adapter threw; the response stays honest."
}
Your client must check the per-signal status / unavailable fields — the HTTP status will not tell you a signal failed. This is deliberate: one dead county must not nuke the whole response.
mcp — JSON-RPC errors
/api/mcp speaks JSON-RPC 2.0. Transport-level failures (bad JSON, missing method) ride HTTP 400; everything else rides HTTP 200 with the error in the body (functions/lib/mcp.js):
HTTP/1.1 200 OK
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32001,
"message": "invalid api key",
"data": { "hint": "get a free key: POST /api/keys" }
}
}
| code | message | http | meaning |
|---|---|---|---|
-32700 | parse error: body is not valid JSON | 400 | request body isn't JSON |
-32600 | invalid request: need {jsonrpc:"2.0", method} | 400 | missing/invalid envelope |
-32601 | method not found: … / unknown tool: … | 200 | superseded handshake method, or an unknown tool name |
-32602 | invalid params: arguments.lat and arguments.lng must be finite numbers | 200 | tool args wrong type (radius_km must be a positive number when provided) |
-32603 | internal error: … | 200 | adapter threw during the call |
-32001 | invalid api key | 200 | same gates as /api/intel: key invalid |
-32002 | rate_limit_exceeded | 200 | per-key/IP token bucket — data.retry_after_seconds |
-32003 | daily_quota_exceeded | 200 | day's calls spent — data carries reason, quota, hint |
-32004 | signal_not_entitled | 200 | data.denied names the refused signal(s) |
The -32001…-32004 codes mirror the REST gates in the same order (key → rate → quota → entitlement), so one client maps to both.
planned: v1 error envelope DRAFT
The v1 signal APIs (POST /v1/signals/{signal}/evaluate) are not shipped yet. The error helpers below exist in functions/lib/v1.js — every v1 response carries request_id — but no deployed endpoint calls them. The shapes are exact code; the endpoints are planned:
{
"request_id": "req_01JB9K…",
"ok": false,
"error": "bad_key",
"message": "Missing or invalid API key. Get a free key (1,000 calls/mo, no card): POST /api/keys"
}
| code | http | planned meaning |
|---|---|---|
bad_key | 401 | missing or invalid key (anonymous evaluate isn't allowed — metering needs an identity) |
quota_exhausted | 402 | monthly free-tier quota exhausted — hard stop at cap, no overage billing, no surprise charges |
rate_limited | 429 | body carries retry_after_seconds; no Retry-After header on v1 |
idempotency_key_required | 400 | Idempotency-Key header required on the POST (retries with the same key + payload are free for 24h) |
Also in functions/lib/v1.js, unexercised by any shipped endpoint: parcel_key validation strings (parcel_key must be an object {state_fips, county_fips, apn_normalized}, 2-digit state FIPS, 3-digit county FIPS, apn_normalized non-empty ≤64 chars; nested parcel lists are not accepted; use parcels[] of single-parcel objects). These become live the day the evaluate endpoints ship.
X-RateLimit-Limit / X-RateLimit-Remaining header convention anywhere in the codebase — retry guidance lives in the Retry-After/retry-after header and the retry_after_seconds body field. There is no IP lockout, ban list, or 403-on-abuse: abuse spikes are logged with the rate_limited error class and surface in /api/health. No endpoint invents an error code — if you receive a shape not on this page, it came from code that changed after this page was written.next: rate limits · api keys · getting started · api reference · docs hub