> ## Documentation Index
> Fetch the complete documentation index at: https://docs.homeservicedata.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# API Error Codes and How to Handle Them

> HTTP status codes, error response shape, real error messages, rate-limit headers, and a retry strategy for the Home Service Data REST API.

When a request cannot be fulfilled, the API returns a non-2xx HTTP status code and an error envelope in the response body. The envelope follows the same three-field structure as successful responses, with `data` set to `null` and the human-readable reason in `error`. Understanding the specific message — not just the status code — is the fastest path to a working fix.

## Error Envelope

Every error response has this shape:

```json theme={null}
{
  "data": null,
  "error": "Missing x-api-key header.",
  "meta": {
    "request_id": "req_01J...",
    "api_version": "2026-06-30",
    "dataset_version": null,
    "trust": {
      "endpoint": "/api/v1/search",
      "level": "mixed",
      "use_case": "Search across entitled datasets.",
      "data_rights_note": "Use result-level quote-safe labels before quoting.",
      "required_response_fields": ["quote_safe"]
    },
    "performance": {
      "target_ms": 300,
      "warn_ms": 600,
      "max_response_bytes": 65536,
      "basis": "server"
    }
  }
}
```

<ResponseField name="data" type="null">
  Always `null` on error responses.
</ResponseField>

<ResponseField name="error" type="string">
  A plain-English description of what went wrong. The message is stable and safe to log, display in developer tooling, or use in conditional logic.
</ResponseField>

<ResponseField name="meta" type="object | null">
  Request metadata useful for support and debugging. Auth, entitlement, and route errors normally include `request_id`, `api_version`, trust metadata, and performance budget metadata. Some low-level framework errors may not include this envelope.
</ResponseField>

## HTTP Status Codes

### 400 — Bad Request

The request was well-formed HTTP but contained invalid or missing parameters. The `error` field explains exactly which parameter is the problem. Do not retry a 400 — fix the request first.

<CodeGroup>
  ```json Missing required parameter theme={null}
  {
    "data": null,
    "error": "Query parameter 'q' is required (min 2 characters).",
        "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Invalid enum value theme={null}
  {
    "data": null,
    "error": "Unsupported trade \"painting\". Use solar, hvac, roofing, plumbing, electrical, or context.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Invalid state code theme={null}
  {
    "data": null,
    "error": "Invalid state. Use a two-letter state code.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Invalid quote_safe value theme={null}
  {
    "data": null,
    "error": "Invalid quote_safe. Use true or false.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Missing benchmark metric theme={null}
  {
    "data": null,
    "error": "Missing required parameter: metric.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```
</CodeGroup>

**Common causes:** missing required parameters, values outside of documented enums, malformed numbers or dates, or search queries shorter than two characters.

***

### 401 — Unauthorized

The request was rejected because no valid API key was found. Check that:

1. The `x-api-key` header is present on every request.
2. The key value is copied exactly — no leading/trailing whitespace.
3. The key has not been revoked.

<CodeGroup>
  ```json Missing header theme={null}
  {
    "data": null,
    "error": "Missing x-api-key header.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Invalid key theme={null}
  {
    "data": null,
    "error": "Invalid API key.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```
</CodeGroup>

***

### 403 — Forbidden

Your API key is valid but does not have the access level required for the requested dataset or endpoint.

<CodeGroup>
  ```json Dataset access required theme={null}
  {
    "data": null,
    "error": "Dataset access required: finance_fees requires production access.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Deactivated key theme={null}
  {
    "data": null,
    "error": "API key is deactivated.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Organization scope missing theme={null}
  {
    "data": null,
    "error": "Dataset access requires an organization-scoped API key.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```
</CodeGroup>

Dataset access levels are `sample → sandbox → production`. If your key has `sandbox` access and the endpoint requires `production`, you will receive a 403. Contact your account manager to upgrade entitlements.

When a 403 is returned for a dataset access violation, the response also includes diagnostic headers:

| Header                  | Value                                                  |
| ----------------------- | ------------------------------------------------------ |
| `X-HSD-Dataset`         | The dataset key that was checked (e.g. `finance_fees`) |
| `X-HSD-Access-Required` | The minimum access level needed (e.g. `production`)    |
| `X-HSD-Access-Current`  | Your current effective access level (e.g. `sandbox`)   |

***

### 404 — Not Found

The requested resource does not exist. This can mean a nonexistent endpoint path, or an entity ID that is not in the database. Verify the URL path and any ID parameters.

***

### 429 — Rate Limited

Your API key has exceeded either its per-minute request rate or its monthly quota.

<CodeGroup>
  ```json Per-minute rate limit theme={null}
  {
    "data": null,
    "error": "Rate limit exceeded. Maximum 60 requests per minute.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```

  ```json Monthly quota exceeded theme={null}
  {
    "data": null,
    "error": "Monthly API quota exceeded.",
    "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
  }
  ```
</CodeGroup>

**Rate-limit headers** are included on every 429 response:

| Header                  | Description                                             |
| ----------------------- | ------------------------------------------------------- |
| `X-RateLimit-Limit`     | The maximum requests allowed per minute for your key    |
| `X-RateLimit-Remaining` | Requests remaining in the current window                |
| `Retry-After`           | Seconds to wait before retrying (per-minute limit only) |

For monthly quota exhaustion, the response includes `X-MonthlyQuota-Limit` and `X-MonthlyQuota-Remaining: 0`. Monthly quotas reset at UTC midnight on the first of each month.

***

### 500 — Internal Server Error

An unexpected error occurred on the server. The platform team is automatically alerted. Retry with exponential backoff (see below). If the error persists for more than a few minutes, contact support.

```json theme={null}
{
  "data": null,
  "error": "Internal server error.",
  "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
}
```

***

### 503 — Service Unavailable

The rate-limiting subsystem is temporarily unavailable. The request was not counted against your limit. Treat this the same as a 500 and retry with backoff.

```json theme={null}
{
  "data": null,
  "error": "API key rate limiting is temporarily unavailable.",
  "meta": { "request_id": "req_01J...", "api_version": "2026-06-30" }
}
```

***

## Retry Strategy

<Warning>
  Never retry 4xx errors except 429. Retrying a 400 or 401 without fixing the underlying problem will burn your quota and produce the same result every time.
</Warning>

Use exponential backoff with jitter for 429 and 5xx errors:

```typescript theme={null}
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 4) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.ok) return response;

    const status = response.status;

    // Do not retry client errors (except 429)
    if (status >= 400 && status < 500 && status !== 429) {
      throw new Error(`Client error ${status}: ${await response.text()}`);
    }

    if (attempt === maxRetries) {
      throw new Error(`Request failed after ${maxRetries} retries: ${status}`);
    }

    // Respect Retry-After on 429; otherwise use exponential backoff
    const retryAfterHeader = response.headers.get("Retry-After");
    const retryAfterSeconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null;
    const backoffMs = retryAfterSeconds
      ? retryAfterSeconds * 1000
      : Math.min(1000 * 2 ** attempt + Math.random() * 500, 30_000);

    await new Promise((resolve) => setTimeout(resolve, backoffMs));
  }
}
```

**Rules of thumb:**

* Start with a 1-second base delay and double on each retry.
* Add random jitter (up to 500 ms) to avoid thundering-herd effects.
* Cap the maximum wait at 30 seconds.
* Stop retrying after 4 attempts and surface the error to your monitoring system.
* Always honour the `Retry-After` header value on 429 responses — it tells you exactly when the rate-limit window resets.
