Error Handling

Error response format, common status codes, and retry strategies.

Error Response Format

Error responses return an error field with a human-readable message. Some errors include additional context fields:

Code
{
  "error": "Invalid or expired API key."
}

Rate limit and quota errors include limit details alongside the message:

Code
{
  "error": "Rate limit exceeded.",
  "limit": 10,
  "retryAfterSeconds": 45
}

Validation errors (malformed parameters) return the failing issues from the request validator:

Code
{
  "success": false,
  "error": {
    "issues": [
      {
        "validation": "uuid",
        "code": "invalid_string",
        "message": "Invalid uuid",
        "path": ["id"]
      }
    ],
    "name": "ZodError"
  }
}

Common HTTP Status Codes

| Code | Meaning | When You'll See It | |------|---------|-------------------| | 400 | Bad Request | Invalid query parameters, malformed request body | | 401 | Unauthorized | Missing, invalid, revoked, or expired API key | | 404 | Not Found | Parcel ID, county, or resource does not exist | | 429 | Too Many Requests | Per-minute rate limit or monthly quota exceeded | | 500 | Internal Server Error | Something went wrong on our end |

Rate Limiting

When you exceed your per-minute rate limit, the API responds with 429 Too Many Requests and a Retry-After header (seconds to wait):

Code
HTTP/1.1 429 Too Many Requests
Retry-After: 45
Code
{
  "error": "Rate limit exceeded.",
  "limit": 10,
  "retryAfterSeconds": 45
}

When you exhaust your monthly quota, the API also responds with 429:

Code
{
  "error": "Monthly request limit exceeded.",
  "limit": 50,
  "used": 50,
  "resetDate": "2026-09-01T00:00:00.000Z"
}

Retry Strategy

For transient errors (429 and 5xx), use exponential backoff with jitter:

Code
async function fetchWithRetry(url: string, apiKey: string, maxRetries = 3): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (res.ok) return res;

    // Don't retry client errors (except 429)
    if (res.status >= 400 && res.status < 500 && res.status !== 429) {
      throw new Error(`Client error: ${res.status}`);
    }

    if (attempt === maxRetries) {
      throw new Error(`Failed after ${maxRetries} retries: ${res.status}`);
    }

    // Exponential backoff with jitter; honor Retry-After when present
    const retryAfter = res.headers.get("Retry-After");
    const baseDelay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000 * Math.pow(2, attempt);
    const jitter = Math.random() * 1000;
    await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
  }

  throw new Error("Unreachable");
}

Note: a monthly-quota 429 (body contains resetDate) will not succeed on retry until the quota period resets -- treat it as terminal, not transient.