> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.westiq.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.westiq.ai/_mcp/server.

# Errors & Limits

Operation errors use a machine-readable envelope, with correlation IDs you can
quote to support. The [prepared Completed Update Delivery contract](/guides/completed-delivery)
is **disabled by default and not available on deployed endpoints**: it additionally
defines empty routing 404/405 responses and a delivery-specific null-field rule,
described below. This page covers the shared envelope, error codes and limits.

## The ApiError envelope

```json
{
  "schemaVersion": "1",
  "code": "request.validation",
  "message": "topK must be between 1 and 100.",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
}
```

* `code` is the stable, machine-readable value to branch on — codes are
  additive-only and never renamed.
* `message` is human-readable and actionable. It never leaks internals (no
  stack traces, no provider errors).
* `correlationId` always echoes the request's correlation ID.
* For the existing endpoints described by this shared envelope, `ruleId` appears
  only when a policy rule denied the request and is omitted otherwise. The
  [completed-delivery contract](/guides/completed-delivery#refusals-and-retry)
  instead always includes `ruleId`, using explicit `null` when no rule applies.
* `availableFrom` (a date, `YYYY-MM-DD`) appears only with
  `integration.not_enabled`, and only when a date is planned for that
  endpoint. It is otherwise absent, never `null`.

### Completed-delivery routing and operation errors

Completed delivery remains unavailable until separately activated. Its operation
refusals use the shared field names with explicit nullable `ruleId`; see its
[status/code table](/guides/completed-delivery#refusals-and-retry). Unknown or malformed
delivery paths produce empty 404, and unsupported methods on exact known paths
produce empty 405 with `Allow`, before authentication or body processing. These
[routing responses](/guides/completed-delivery#empty-routing-responses) have no
error envelope. Existing unrelated routes retain their behavior. A delivery timeout
or disconnect never proves rollback; preserve request identity when resolving ambiguity.

## Error codes

| Code                         | HTTP            | Meaning                                                                                                                                                                                                                                                                                                                                                        |
| ---------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.invalid_token`         | 401             | Missing, malformed, expired, bad-signature, or wrong-issuer token — or a token that is not an access token                                                                                                                                                                                                                                                     |
| `auth.insufficient_scope`    | 403             | Token valid, but the `westiq/search` scope is absent                                                                                                                                                                                                                                                                                                           |
| `auth.unknown_client`        | 403             | Token valid, but the client has no airline mapping (fail-closed — never a default tenant)                                                                                                                                                                                                                                                                      |
| `integration.not_enabled`    | 403             | This capability is not yet enabled for your integration. The credential is valid and the endpoints enabled for it keep working; repeating the call will not help until this one is enabled. When a date is planned, `availableFrom` carries it                                                                                                                 |
| `request.malformed`          | 400 / 413       | Body could not be accepted: not JSON, unparseable, empty (400) — or over the body-size cap (413)                                                                                                                                                                                                                                                               |
| `request.validation`         | 422\*           | Body parsed but violates a rule (query length, `topK` range, batch size, `{"events": null}`)                                                                                                                                                                                                                                                                   |
| `session.unresolved`         | 400             | A session-scoped endpoint was called without a resolvable passenger session — a missing or key-unsafe `X-Session-Id` header (or `{id}` path segment on the SSE routes). See [Sessions & Member Locators](/guides/sessions)                                                                                                                                     |
| `content.refused`            | 422             | The two natural-language search endpoints only ([`/v1/search`](/guides/search), [`/v1/shopping/search`](/guides/shopping-search)) — the query itself was refused on safety or scope grounds. The message is generic by design and no rule identifier is disclosed. Not retryable: rephrase the query. See [content safety](/guides/search#content-safety)      |
| `rate.limited`               | 429             | An AI-backed **or partner** endpoint was called over your per-client request-rate limit. The `Retry-After` response header says how many seconds to back off. See [Rate limits](#rate-limits) below                                                                                                                                                            |
| `ingest.batch_too_large`     | 422             | [`POST /v1/catalog/ingest/jobs`](/guides/catalog-ingest) and [`POST /v1/products/ingest/jobs`](/guides/products-ingest) — more than 500 items in one batch. Split it and resubmit; the batch is never partially ingested                                                                                                                                       |
| `ingest.mixed_currency`      | 422             | [`POST /v1/products/ingest/jobs`](/guides/products-ingest) only — the batch carries two or more well-formed currencies. Rejected whole, never converted; split by currency and resubmit                                                                                                                                                                        |
| `ingest.invalid_artwork_url` | per-item report | Catalog or product artwork contains an empty, relative, non-HTTPS, over-2,048-character, whitespace-containing, or hostless URL. The bad item is rejected; otherwise-valid URLs are not checked for reachability.                                                                                                                                              |
| `ingest.job_not_found`       | 404             | The two job-status routes ([catalog](/guides/catalog-ingest#polling-the-job), [products](/guides/products-ingest#polling-the-job)) — no such job for your airline **on this surface**. Airline-scoped, so another tenant's job id is simply not found — and each surface reads only its own jobs, so a catalog job id polled on the product route is a 404 too |
| `flight.not_found`           | 404             | [`PUT /v1/flights/{id}/active-catalog`](/guides/flights#active-catalog) — no flight is registered under that id. [Register it first](/guides/flights#register-a-flight)                                                                                                                                                                                        |
| `theme.not_found`            | 404             | `GET /v1/theme` — no theme is configured for your airline. Fail-loud, never a default/fallback theme                                                                                                                                                                                                                                                           |
| `internal.error`             | 500             | Unexpected failure. The message is generic — the `correlationId` is the debugging handle                                                                                                                                                                                                                                                                       |

> **Note**
>
> Without a token, unmatched routes return **401**, by design: the API denies
> by default, so a typo'd path looks like an auth failure. With a valid token,
> an unmatched route returns an empty `404`. If a request 401s unexpectedly,
> check the path first.

\* One exception: [`POST /v1/destination/weather`](/guides/destination-weather) returns
`request.validation` as a **400**, not a 422, when none of `airport`, `city`, or a `lat`+`lon`
pair is supplied. Every other endpoint on the platform pairs this code with 422 — branch on the
`code` field, not the status, if you handle this generically.

> **Note**
>
> **Two different 422s on the search endpoints.** A `422 request.validation` means *your
> request* broke a rule — fix the field and retry. A `422 content.refused` means the *query
> text* was refused on safety/scope grounds — show the response's message to the passenger and
> don't retry the same query. Branch on `code`, never on the 422 status alone.

> **Note**
>
> **`ingest.*` is mostly a *per-item* vocabulary, not an HTTP one.** Codes
> like `ingest.unknown_kind`, `ingest.missing_price`, or
> `ingest.ambiguous_match` appear inside an ingest job's report
> ([catalog](/guides/catalog-ingest#per-item-rejection-codes),
> [products](/guides/products-ingest#per-item-rejection-codes)), one per
> rejected item, and never as the `code` of an HTTP error. The three
> exceptions are in the table above: `ingest.batch_too_large`,
> `ingest.mixed_currency`, and `ingest.job_not_found`. Everything else that
> fails at the HTTP level on these routes — including an over-cap
> `externalTitleIds` list on the [flights push](/guides/flights#active-catalog) — is the ordinary
> `request.validation`.

## Correlation IDs

Every request is assigned a correlation ID of the form `req_` followed by
exactly 32 hex characters. You can supply your own on the `X-Correlation-Id`
request header — if it matches that format it is honored verbatim; anything
else (or no header) is replaced with a freshly minted ID. A bad value is
never a rejection.

The established ID is echoed back on the `X-Correlation-Id` response header
and inside every error body. **Log it, and quote it in support requests** —
it is how we find your request in our logs, and for a `500 internal.error` it
is the only handle there is.

## Request limits

| Limit                                                           | Value           | Over the limit               |
| --------------------------------------------------------------- | --------------- | ---------------------------- |
| `/v1/search` body size                                          | 64 KiB          | 413 `request.malformed`      |
| `/v1/events` body size                                          | 2 MiB           | 413 `request.malformed`      |
| [Scored serve](/guides/catalog-serve#scored-serve) body size    | 16 KiB          | 413 `request.malformed`      |
| [Catalog-ingest](/guides/catalog-ingest) body size              | 8 MiB           | 413 `request.malformed`      |
| [Product-ingest](/guides/products-ingest) body size             | 8 MiB           | 413 `request.malformed`      |
| [Flight context](/guides/flights) body size                     | 64 KiB          | 413 `request.malformed`      |
| [Active-catalog push](/guides/flights#active-catalog) body size | 2 MiB           | 413 `request.malformed`      |
| `query` length                                                  | 1000 characters | 422 `request.validation`     |
| `topK` range                                                    | 1–100           | 422 `request.validation`     |
| Events per batch                                                | 1–500           | 422 `request.validation`     |
| Titles per ingest batch                                         | 1–500           | 422 `ingest.batch_too_large` |
| Products per ingest batch                                       | 1–500           | 422 `ingest.batch_too_large` |
| Ids per active-catalog push                                     | 1–10,000        | 422 `request.validation`     |

## Rate limits

Independent layers protect the platform, tied to different identities: two
per-client-credential layers (AI-backed endpoints and partner endpoints, each
split into their own classes) and one per-IP layer at the edge.

### Per client credential, on AI-backed endpoints (429)

Every **AI-backed** `/v1` endpoint enforces a request-rate limit per client
credential, per endpoint class: [search](/guides/search),
[shopping search](/guides/shopping-search),
[shopping curate & rescore](/guides/shopping-curate),
[suggestions](/guides/suggestions), [persona generation](/guides/personas),
[enrichment](/guides/enrichment), [ads decisioning](/guides/ads-decision),
and [destination plan/extend](/guides/destination-plan) and
[search/replace](/guides/destination-search). The model-free endpoints —
healthz, theme, catalog serve, events, session routes,
[destination weather](/guides/destination-weather), and
[hero images](/guides/hero-images) — are not rate-limited at this layer. The
[partner endpoints](#partner-rate-limits) are
model-free but *are* limited, in their own classes — see below.

Over the limit, the endpoint returns **`429 rate.limited`** in the standard
ApiError envelope (generic message, correlation ID echoed) with a
**`Retry-After` response header giving the back-off in seconds**. Handle it
by pausing requests to that endpoint class for `Retry-After` seconds, then
resuming. Three properties to build against:

* **Limits are per endpoint class** — one endpoint class hitting its limit
  does not block the others (a search burst never starves your event
  ingestion, which isn't limited at all).
* **The numeric limit is deployment-tuned, not a contract.** Don't hard-code
  a requests-per-minute figure — honor `Retry-After` instead. Well-behaved
  seatback traffic does not hit this limit; retry storms and hammering loops
  do.
* **A 429 is not a fault.** Log it, back off, resume. Treat only repeated
  429s *after* honoring `Retry-After` as a support conversation (quote the
  `correlationId`).

### Per client credential, on the partner endpoints (429) \[#partner-rate-limits]

The partner surfaces — [catalog-ingest](/guides/catalog-ingest) and
[product-ingest](/guides/products-ingest) job submission, each surface's
**job-status polling**, and [flight registration and the active-catalog push](/guides/flights) — carry the same kind of per-client limit, each as its
own class, with the same `429 rate.limited` + `Retry-After` behavior.

They are deliberately generous: sized so that legitimate operational bursts —
a boarding wave's registrations and retries, or a full-catalog resync
submitting a batch train while sweeping every job's status — never see a
`429`.

> **Note**
>
> **Status polling has its own budget.** Job submission and job-status
> polling are separate classes with separate windows, so a submission burst
> can never blind your status polling, and a status sweep can never consume
> your submission headroom. You do not need a special polling cadence to
> protect your submits — though a job that takes minutes doesn't need
> second-resolution progress either; a few seconds between polls of the same
> job is plenty.

> **Warning**
>
> **On the two ingest-submission POSTs (`/v1/catalog/ingest/jobs`,
> `/v1/products/ingest/jobs`), a 429 has two causes with two different
> bodies** — the usage ceiling (ceiling-exceeded body, with `resetAtUtc`)
> and the per-client request rate (this envelope, with `Retry-After`).
> [Branch on the body shape](/guides/catalog-ingest#request-rate-limits),
> not the status code.

### Per IP, at the edge (bare 403)

The platform edge applies a per-IP limit of **2000 requests per 5-minute
window**. Requests over the limit are blocked **at the edge** with a plain
`403`, *without* the ApiError envelope, until the rate drops. The edge sends
the same 403 for other causes too: see [The edge 403](#edge-403).

## The edge 403 \[#edge-403]

The platform edge sits in front of the API and can refuse a request before the
API sees it, with a plain `403` whose body is HTML (`text/html`), not JSON: no
ApiError envelope and no `code`. Among other causes, the edge sends this 403
for:

* unauthenticated traffic (no `Bearer` token) without a `User-Agent` header or
  with a body over 8 KB;
* a call outside `/v1`, such as `GET /healthz` or the API document at
  `GET /openapi/v1.json`, without a `User-Agent`, even with a token;
* a request rate over the [per-IP limit](#rate-limits) of 2000 requests per
  5-minute window.

Authenticated `/v1` calls are not refused for a missing `User-Agent` or a body
over 8 KB; the API's own [request limits](#request-limits) apply to them. We
recommend a `User-Agent` on every call, for example
`User-Agent: your-integration/1.0`.

That is how you tell the 403s apart:

* **403 with a JSON body and a `code`** — an API answer:
  `auth.insufficient_scope` or `auth.unknown_client` (fix the token or
  credential), or `integration.not_enabled` (the endpoint is not enabled for
  your integration). Retrying won't help.
* **403 with an HTML body and no envelope** — the edge. If the request
  carries your token and a `User-Agent`, the likely cause is the rate limit:
  it clears on its own once your request rate drops below the limit, so back
  off and retry with jitter. If either is missing, add it and retry.

## Two layers of "rejected" on /v1/events

`/v1/events` has a second, per-event rejection layer that is **not** an HTTP
error: individually invalid events come back inside the 200 response's
`rejected[]` array with their own code vocabulary (`event.*`), while the
ApiError envelope covers batch-level failures only. The split — and the five
per-event codes — are covered in the
[behavioral events guide](/guides/behavioral-events#batch-level-vs-per-event-failures).