# Error Handling

Every non-2xx response from the Not AI Public REST API uses the same JSON envelope and the same enumerated code list. Treat the HTTP status as the coarse signal and the `error.code` string as the stable identifier you write logic against.

## Error envelope

```json
{
  "error": {
    "code": "INVALID_API_KEY",
    "message": "The supplied API key is not valid.",
    "details": {
      "field": "..."
    }
  }
}
```

- `code` is a stable identifier from the enumerated list below. Codes never change meaning inside a major version.
- `message` is a human-readable sentence. Treat it as opaque. Copy changes are not breaking.
- `details` is an optional object with field-level context. Validation errors populate it with the offending field name; most other errors omit it.

Health (`GET /health`) and the OpenAPI document (`GET /openapi/v3.json`) are the only routes that never return this envelope. They have no error responses defined in v1.

## HTTP status codes

| Status | Meaning |
|--------|---------|
| `200` | Success. Body is the response envelope. |
| `204` | Success with no body. Used where the operation is intentionally empty. |
| `400` | The request was malformed or failed validation. `details` will name the offending field. |
| `401` | The request was unauthenticated. The key was missing (`MISSING_API_KEY`) or invalid (`INVALID_API_KEY` — malformed, unknown, or for the wrong region). |
| `402` | A plan-tier limit was reached (e.g. registering a webhook subscription past the plan's cap). Upgrade or free a slot. |
| `403` | The key is valid but is not permitted to access the requested resource (for example, a session that belongs to another integration). |
| `404` | The requested resource does not exist. |
| `429` | A per-minute rate-limit window was exhausted. A `Retry-After` header is included when the edge can compute one; if absent, back off with exponential delay. See [Rate Limits](/developers/rate-limits). |
| `5xx` | The API failed to serve the request. Retryable for idempotent reads. |

## Stable error codes

These codes are stamped on the v1 contract. New codes may be added in v1 (additive change); existing codes will never be removed or repurposed.

| Code | Typical status | When you see it |
|------|----------------|-----------------|
| `MISSING_API_KEY` | 401 | The request carried no `Authorization: Bearer` and no `x-api-key` header. |
| `INVALID_API_KEY` | 401 | The presented key was malformed, unknown, revoked, or for the wrong region. Identical envelope for all four cases by design. |
| `INVALID_REQUEST` | 400 | A query string or path parameter failed validation. `details` names the offending field. |
| `INVALID_BODY` | 400 | The request body was malformed, missing a required field, or otherwise not valid JSON for the target route. |
| `INVALID_URL` | 400 | A webhook subscription URL was rejected by the SSRF / format validator (bad scheme, non-80/443 port, userinfo, private/loopback/link-local host, blocked metadata host, URL longer than 2048 chars). |
| `INVALID_EVENT_TYPE` | 400 | A webhook subscription's `eventType` was not one of the documented event-type strings. |
| `NOT_FOUND` | 404 | The path resolved to a resource that does not exist or is not visible to this integration. |
| `FORBIDDEN` | 403 | The key resolved successfully but cannot access the resource. |
| `PLAN_LIMIT_REACHED` | 402 | The integration is at the plan-tier cap for the resource (e.g. webhook subscriptions). Free a slot or upgrade. |
| `RATE_LIMITED` | 429 | A per-minute rate-limit window was exhausted. `Authorization: Bearer` callers are isolated by key; `x-api-key` callers share a per-tier edge bucket and can be throttled by unrelated same-tier traffic. Honor `Retry-After` when present; otherwise back off exponentially. |
| `UNSUPPORTED_OPERATION` | 400 | The endpoint is documented but the specific combination of arguments is not yet supported. Adjust the request. |
| `SERVICE_UNAVAILABLE` | 503 | A downstream dependency is unavailable. Retry with backoff. |
| `INTERNAL_ERROR` | 500 | An unhandled exception was caught by the global error middleware. Retry with backoff and report if it persists. |

## Examples

400, bad query string:

```json
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "pageSize must be between 1 and 100.",
    "details": { "field": "pageSize" }
  }
}
```

401, the credential was malformed or unknown:

```json
{
  "error": {
    "code": "INVALID_API_KEY",
    "message": "The supplied API key is not valid."
  }
}
```

401, no credential was presented:

```json
{
  "error": {
    "code": "MISSING_API_KEY",
    "message": "Provide your API key via 'Authorization: Bearer aik_v1_...' or the 'x-api-key' header."
  }
}
```

404, unknown session id:

```json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "No session with that id is visible to this integration."
  }
}
```

500, unhandled server-side fault:

```json
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred."
  }
}
```

## Retry semantics

The API performs no automatic server-side retries on your behalf. Clients should implement their own retry policy with the following rules:

- **2xx**. Never retry.
- **400, 401, 403, 404**. Never retry. Fix the request and try again.
- **429**. Honor `Retry-After` when the response includes it; otherwise back off with exponential delay and jitter. Do not retry sooner than the floor of either path.
- **5xx (500, 502, 503, 504)**. Retry the request with exponential backoff and jitter. `GET` retries are always safe. For mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) verify the operation is idempotent at your endpoint before retrying — `PUT` / `DELETE` are idempotent by design, `POST` create endpoints document their idempotency behavior per route.

A reasonable default policy for a server-to-server consumer:

```python
import os
import time
import random
import requests

BASE_URL = "https://stg-api.isnotai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['ISNOTAI_API_KEY']}"}

def get_with_retry(path, params=None, max_attempts=5):
    for attempt in range(max_attempts):
        response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params, timeout=10)

        if response.status_code < 400:
            return response.json()

        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                time.sleep(int(retry_after))
            else:
                time.sleep((2 ** attempt) + random.random())
            continue

        if 500 <= response.status_code < 600:
            backoff = (2 ** attempt) + random.random()
            time.sleep(backoff)
            continue

        response.raise_for_status()

    raise RuntimeError(f"giving up on {path} after {max_attempts} attempts")
```

Log the full error envelope (`code`, `message`, and `details`) when a retry policy gives up. That is what Not AI support will need to triage.
