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

# Errors

> The typed error envelope. Every error code, what triggers it, what to do, what status it carries. Trace IDs on every request.

## The envelope

Every non-2xx response has the same shape:

```json theme={null}
{
  "error": "<stable_code>",
  "message": "<human readable, English, may change>",
  "trace_id": "<16-hex>",
  "...": "code-specific extras"
}
```

Three rules:

1. **`error` is a stable enum.** Match on it in code. We never change a code without a major version bump.
2. **`message` is for humans.** Show it in UIs. Wording may evolve; do not parse.
3. **`trace_id` is the support handshake.** Quote it in any ticket — we resolve in one round trip.

Every response (success and error) also returns the trace id in the `X-Exorde-Trace-Id` header.

## Authentication errors

| Status | `error`             | When                                                   | What to do                                         |
| ------ | ------------------- | ------------------------------------------------------ | -------------------------------------------------- |
| 401    | `missing_api_key`   | `X-API-Key` header absent                              | Add the header                                     |
| 401    | `invalid_api_key`   | Unknown / rotated / revoked key                        | Mint via [/v1/keys/trial](/quickstart) or rotate   |
| 401    | `malformed_api_key` | Header present but doesn't match `exd_<tier>_<token>`  | Check for stray whitespace, quotes                 |
| 403    | `key_expired`       | Past `expires_at`                                      | Upgrade or mint a new trial with a different email |
| 403    | `key_revoked`       | Key was explicitly deleted via DELETE /v1/keys/current | Mint a new key                                     |

Sample:

```json theme={null}
{
  "error": "invalid_api_key",
  "message": "API key not recognised",
  "trace_id": "8b3a47ce91d04f17"
}
```

## Authorisation errors

| Status | `error`            | When                                                         | What to do               |
| ------ | ------------------ | ------------------------------------------------------------ | ------------------------ |
| 403    | `topic_denied`     | Key valid but not scoped to the requested topic              | Upgrade to add the topic |
| 403    | `upgrade_required` | Endpoint requires a higher tier                              | Upgrade                  |
| 403    | `feature_disabled` | Feature gated off for your account (rare; bespoke contracts) | Contact support          |

`upgrade_required` carries upgrade context so a UI can render a CTA without a second roundtrip:

```json theme={null}
{
  "error": "upgrade_required",
  "message": "This endpoint requires the 'see' tier",
  "feature": "clusters",
  "current_tier": "watch",
  "required_tier": "see",
  "upgrade": true,
  "trace_id": "8b3a47ce91d04f17"
}
```

`topic_denied` echoes which topics you do have:

```json theme={null}
{
  "error": "topic_denied",
  "message": "Key not authorised for topic 'cyber'",
  "requested_topic": "cyber",
  "allowed_topics": ["global"],
  "trace_id": "8b3a47ce91d04f17"
}
```

## Resource errors

| Status | `error`             | When                                               | What to do                                            |
| ------ | ------------------- | -------------------------------------------------- | ----------------------------------------------------- |
| 404    | `unknown_topic`     | Topic slug not in curated set                      | Use [/v1/topics](/topics-and-watchlists) for the list |
| 404    | `unknown_watchlist` | Watchlist id not found or not owned by this client | Use `GET /v1/watchlists`                              |
| 404    | `unknown_cluster`   | `cluster_id` not in the requested topic snapshot   | Re-query `/clusters`                                  |
| 404    | `unknown_entity`    | Entity not in the requested topic snapshot         | Re-query `/entities`                                  |
| 404    | `unknown_narrative` | Narrative id not in topic snapshot                 | Re-query `/narratives`                                |
| 404    | `report_not_found`  | No report yet for this topic                       | Try `/reports/archive` for older reports              |
| 410    | `snapshot_expired`  | Requested snapshot rolled out of retention         | Use `latest` or a recent `snapshot_id`                |

## Validation errors

| Status | `error`                        | When                                                         |
| ------ | ------------------------------ | ------------------------------------------------------------ |
| 422    | `validation_error`             | Pydantic validation failed (missing field, wrong type, etc.) |
| 422    | `invalid_email`                | Trial mint with a non-RFC-compliant email                    |
| 422    | `invalid_term_type`            | Watchlist `term.type` not in `keyword/phrase/entity/domain`  |
| 422    | `empty_terms`                  | Watchlist payload with `terms: []`                           |
| 422    | `watchlist_term_limit_reached` | More terms than tier allows                                  |
| 422    | `invalid_base_topic`           | Watchlist `base_topic` not curated                           |
| 422    | `invalid_signal_type`          | Subscription with unknown `signal_type`                      |
| 422    | `invalid_webhook_url`          | Subscription `delivery.url` malformed or non-HTTPS           |
| 409    | `duplicate_watchlist_name`     | Watchlist name already used by this client                   |

Pydantic-style detail when `validation_error`:

```json theme={null}
{
  "error": "validation_error",
  "message": "Request body failed validation",
  "fields": [
    { "loc": ["body", "terms", 0, "type"], "msg": "value is not a valid enum member" },
    { "loc": ["body", "name"],              "msg": "field required" }
  ],
  "trace_id": "8b3a47ce91d04f17"
}
```

## Quota and rate-limit errors

| Status | `error`                      | When                              | What to do                                 |
| ------ | ---------------------------- | --------------------------------- | ------------------------------------------ |
| 429    | `rate_limited`               | RPM exceeded                      | Wait `Retry-After`, retry                  |
| 429    | `monthly_quota_exceeded`     | Calls-per-month cap               | Upgrade or wait for rollover               |
| 429    | `subscription_limit_reached` | Webhook count cap                 | Delete an existing subscription or upgrade |
| 429    | `watchlist_limit_reached`    | Watchlist count cap               | Delete an existing watchlist or upgrade    |
| 429    | `trial_mint_throttled`       | Too many trial mints from this IP | Wait, then retry                           |

`rate_limited`:

```json theme={null}
{
  "error": "rate_limited",
  "message": "Rate limit exceeded — retry after 7s",
  "retry_after_seconds": 7,
  "limit_rpm": 30,
  "trace_id": "8b3a47ce91d04f17"
}
```

`watchlist_limit_reached`:

```json theme={null}
{
  "error": "watchlist_limit_reached",
  "message": "Your tier allows up to 4 watchlists",
  "current_tier": "see",
  "limit": 4,
  "current": 4,
  "trace_id": "8b3a47ce91d04f17"
}
```

Full handling pattern: [Rate limits](/rate-limits).

## Server-side errors

| Status | `error`               | When                                                             | What to do                                                      |
| ------ | --------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
| 500    | `internal_error`      | Uncaught exception                                               | Retry once; if it persists, email support with `trace_id`       |
| 502    | `upstream_error`      | ClickHouse / model service error                                 | Retry with backoff                                              |
| 503    | `service_unavailable` | Status endpoint reports degraded; or service draining for deploy | Retry; check [/v1/status](https://intel-v1.exorde.io/v1/status) |
| 504    | `upstream_timeout`    | Backend query exceeded the per-request budget                    | Retry; if persistent on a specific endpoint, email support      |

`service_unavailable`:

```json theme={null}
{
  "error": "service_unavailable",
  "message": "Backing store is degraded — try again shortly",
  "retry_after_seconds": 5,
  "trace_id": "8b3a47ce91d04f17"
}
```

## Subscription / webhook errors

| Status | `error`                      | When                                                      |
| ------ | ---------------------------- | --------------------------------------------------------- |
| 422    | `invalid_subscription_scope` | `scope` mismatched (e.g. `kind: watchlist` without an id) |
| 422    | `unknown_subscription_type`  | `type` not in `alert/digest/report`                       |
| 409    | `duplicate_subscription`     | Same scope+type+url already exists                        |
| 410    | `webhook_dead`               | Endpoint returned 4xx/5xx N times in a row, auto-paused   |

Webhook delivery (server-to-you, not request errors):

| Header                     | Meaning                                                                 |
| -------------------------- | ----------------------------------------------------------------------- |
| `X-Exorde-Signature`       | `sha256=<hex>` HMAC of the body, signed with your subscription's secret |
| `X-Exorde-Delivery-Id`     | Unique id for this delivery attempt; use to dedup                       |
| `X-Exorde-Subscription-Id` | The subscription that produced this event                               |
| `X-Exorde-Event-Type`      | `alert`, `digest`, `report`                                             |

Verify the signature server-side before trusting the payload.

## Common patterns

### Match on `error`, not status

A 403 can be one of `topic_denied`, `key_expired`, `key_revoked`, `upgrade_required`, `feature_disabled` — all need different UX. Don't hardcode "403 = upgrade prompt"; read `error`.

```python theme={null}
def render_error(envelope: dict) -> str:
    code = envelope["error"]
    if code == "upgrade_required":  return f"Upgrade to {envelope['required_tier']} for {envelope['feature']}"
    if code == "topic_denied":      return f"This topic isn't in your plan. You have: {envelope['allowed_topics']}"
    if code == "key_expired":       return "Your trial expired. Renew at intel@exorde.io"
    if code == "rate_limited":      return f"Slow down — try again in {envelope['retry_after_seconds']}s"
    return envelope["message"]  # fallback
```

### Always log `trace_id`

```python theme={null}
try:
    r = httpx.get(url, headers=h)
    r.raise_for_status()
except httpx.HTTPStatusError as e:
    body = e.response.json()
    log.error("intel api error",
              extra={"status": e.response.status_code,
                     "code":   body.get("error"),
                     "trace":  body.get("trace_id")})
    raise
```

### Distinguish "retry" from "act"

| Retryable with no action                                                                             | Need to act     |
| ---------------------------------------------------------------------------------------------------- | --------------- |
| `rate_limited`, `service_unavailable`, `upstream_error`, `upstream_timeout`, `internal_error` (once) | everything else |

Encode this as a function:

```python theme={null}
RETRYABLE = {"rate_limited", "service_unavailable", "upstream_error",
             "upstream_timeout", "internal_error"}

def is_retryable(envelope: dict) -> bool:
    return envelope.get("error") in RETRYABLE
```

## Support handshake

When you email [intel@exorde.io](mailto:intel@exorde.io), include:

1. The full error envelope (especially `trace_id`)
2. The exact request URL and method
3. Your `client_id` (from `/v1/keys/current`, **not** the api\_key itself)
4. Approximate UTC timestamp

We resolve from `trace_id` alone in most cases. The other fields are belt-and-braces.

<Note>Last reviewed: 2026-05-19. API version 1.2.8.</Note>
