> 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.

# Catalog Ingestion

`POST /v1/catalog/ingest/jobs` submits a batch of catalog titles for
ingestion; `GET /v1/catalog/ingest/jobs/{jobId}` reports what happened to it.
Ingestion is **asynchronous**: the submission is accepted immediately with a
job id, and the work runs in the background.

Both endpoints are **machine-to-machine**. They take no `X-Session-Id` — a
catalog load carries no passenger session. Your **airline and your
content-rights ruleset are resolved from your credential** and are never
fields on the request; there is nothing on these shapes on which to assert a
tenant.

> **Note**
>
> Ingestion is the prerequisite for everything else. A title must be
> centrally ingested before a flight can list it in its [active catalog](/guides/flights#active-catalog) — that push resolves external ids,
> it never creates titles.

## Quickstart: ingest, then poll

**1. Submit a batch.**

```bash
curl -X POST "https://api.staging.westiq.ai/v1/catalog/ingest/jobs" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: catalog-2026-07-batch-001" \
  -H "X-Correlation-Id: req_0197c0a1b2c3d4e5f60718293a4b5c6d" \
  -d '{
        "sourceSystem": "partner-cms",
        "items": [
          {
            "externalTitleId": "CMS-88421",
            "kind": "Movie",
            "title": "Northbound",
            "year": 1998,
            "genres": ["Drama"],
            "cast": ["A. Rivera"],
            "rating": "PG",
            "runtimeMinutes": 104,
            "synopsis": "A night-shift dispatcher drives one last delivery…",
            "keywords": ["road movie", "slow cinema"],
            "imdbRating": 7.2,
            "hasAwards": true,
            "artwork": {
              "posterUrl": "https://media.example.com/posters/CMS-88421.webp",
              "heroUrl": "https://media.example.com/heroes/CMS-88421.webp"
            }
          }
        ]
      }'
```

`202 Accepted`:

```json
{
  "schemaVersion": "1",
  "jobId": "0197c0a1-b2c3-7d4e-8f60-718293a4b5c6",
  "status": "queued"
}
```

The `jobId` is a server-minted UUID string, scoped to your airline. Treat it
as opaque.

**2. Poll the job.**

```bash
curl "https://api.staging.westiq.ai/v1/catalog/ingest/jobs/0197c0a1-b2c3-7d4e-8f60-718293a4b5c6" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "schemaVersion": "1",
  "jobId": "0197c0a1-b2c3-7d4e-8f60-718293a4b5c6",
  "status": "completed",
  "report": {
    "schemaVersion": "1",
    "producerId": "partner-http",
    "manifestVersion": "direct",
    "submittedCount": 1,
    "acceptedCount": 1,
    "rejectedCount": 0,
    "unclassifiedHeldCount": 0,
    "genreCoveragePct": 100,
    "castCoveragePct": 100,
    "synopsisCoveragePct": 100,
    "runtimeCoveragePct": 100,
    "keywordCoveragePct": 100,
    "rejections": []
  },
  "mappings": [
    { "externalTitleId": "CMS-88421", "titleId": 10000000, "disposition": "created" }
  ],
  "fieldConflictCount": 0
}
```

`titleId` is the platform id this title now has — **an opaque integer we
assign.** Store it and use it where our other endpoints take a `titleId`
(such as [suggestions](/guides/suggestions) and
[events](/guides/behavioral-events)), but don't infer anything from its size
or range, and don't assume it is near any id you have seen before.

A job that takes minutes does not need second-resolution progress — **a few
seconds between polls of the same job is plenty**.

## Submitting a batch

| Field          | Required | Rules                                                                                                                       |
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `sourceSystem` | yes      | 1–64 characters of lowercase letters, digits, and hyphens (e.g. `"partner-cms"`). Anything else → `422 request.validation`. |
| `items`        | yes      | 1–500 entries. Empty → `422 request.validation`; over 500 → `422 ingest.batch_too_large`.                                   |

The body is capped at **8 MiB** (`413 request.malformed` above it) — a
500-title batch of rich metadata is comfortably inside it.

**`sourceSystem` is your id namespace.** Every `externalTitleId` in the batch
is scoped to it: the same external id under two source systems is two
different identities. Use the same token later when you [push a flight's active catalog](/guides/flights#active-catalog) — that is the join key
between the two calls.

**Batch cap: 500 items per request.** A larger catalog is split across
requests. An oversized batch is rejected **whole**, never partially ingested.

### Item fields

| Field                  | Required | Rules                                                                                                                                                                                                                                                                                              |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalTitleId`      | yes      | Your id for this title, within `sourceSystem`. Non-blank.                                                                                                                                                                                                                                          |
| `kind`                 | yes      | Exactly `"Movie"`, `"TV"`, `"Audio"`, or `"Game"` — **case-sensitive here** (unlike the [serve filter](/guides/catalog-serve) and [search's `kindOverride`](/guides/search#kinds), which accept any casing). Anything else → per-item `ingest.unknown_kind`.                                       |
| `title`                | yes      | Non-blank.                                                                                                                                                                                                                                                                                         |
| `year`                 | no       | Release year. **`0` means unknown** — the field is not nullable, so `0` is the explicit "unknown" value. A non-zero year outside 1900–2100 → `ingest.year_out_of_range`.                                                                                                                           |
| `genres`               | no       | Each entry must come from the [genre vocabulary below](#genres), **matched case-insensitively** — any casing of a known token is accepted, then stored and served in the vocabulary's canonical casing. An unknown token → `ingest.unknown_genre`, never guessed into a nearby one.                |
| `cast`                 | no       | Free-form names.                                                                                                                                                                                                                                                                                   |
| `rating`               | no       | A token from the [rating vocabulary below](#ratings), matched case-insensitively. Unrecognized → `ingest.unknown_rating`. `null` = unknown.                                                                                                                                                        |
| `synopsis`, `language` | no       | `null` = unknown. `language` accepts an ISO 639 code or a language name, stored normalized; it influences **ranking, never visibility**.                                                                                                                                                           |
| `runtimeMinutes`       | no       | Whole minutes, strictly positive. `null` = unknown; `0` or negative → `ingest.runtime_out_of_range`. Feeds [time-aware search](/guides/search#time-aware).                                                                                                                                         |
| `keywords`             | no       | Free-form franchise/entity/theme tokens (`["James Bond", "007"]`) — no fixed vocabulary. These are what [franchise queries](/guides/search#entities) match against, so they are worth sending.                                                                                                     |
| `imdbRating`           | no       | 0–10 scale, one decimal (`7.2`). `null` = not rated. Outside (0, 10] → `ingest.score_out_of_range`. Feeds [quality search](/guides/search#quality).                                                                                                                                                |
| `rtTomatometer`        | no       | 0–10 scale (`8.3`). `null` = unknown. Display-first; the quality filter evaluates `imdbRating`.                                                                                                                                                                                                    |
| `hasAwards`            | no       | `true` when your source carries at least one award-body entry for the title. Default `false` = no award data known — an honest absence, never a judgment. Award names themselves ride in `keywords`.                                                                                               |
| `artwork`              | no       | Optional `{ posterUrl?, heroUrl? }`. Each supplied member must be an absolute HTTPS URL, at most 2,048 characters, with no whitespace and a valid host. Empty, relative, HTTP, or over-length values reject the item as `ingest.invalid_artwork_url`. Omit the block when no artwork is available. |
| `titleId`              | no       | Our platform title id, **only if you already know it**. Omit it if you don't — a value that disagrees with platform state is rejected (`ingest.inconsistent_platform_id`) rather than reconciled.                                                                                                  |
| `rawMetadata`          | no       | String-to-string provenance bag for source fields with no first-class slot. Never read by scoring, ranking, or classification.                                                                                                                                                                     |

The metadata you send here comes back to your UI on [catalog serve](/guides/catalog-serve#item-fields): runtime, language, keywords,
review scores, the award flag, and artwork references are all served per item, so a richer feed
directly improves what you can render — no metadata sidecar needed. Artwork URLs are stored and
returned exactly as supplied; the platform never fetches, resolves, caches, proxies, or checks the
referenced media. Your client decides whether and how to fetch it from the supplied origin.

### Genre vocabulary \[#genres]

Film and general genres: `Action`, `Adventure`, `Animation`, `Biography`,
`Comedy`, `Crime`, `Documentary`, `Drama`, `Family`, `Fantasy`, `History`,
`Horror`, `Music`, `Musical`, `Mystery`, `Romance`, `Sci-Fi`, `Sport`,
`Thriller`, `War`, `Western`.

Television genres: `Culture`, `Food`, `Kids`, `Lifestyle`, `Nature`,
`Reality`, `Science`, `Sitcom`, `Travel` — so a TV catalog ingests without
genre erasure. They are ordinary vocabulary tokens: valid on any kind, and
matched, stored, and served like the rest.

Membership is **case-insensitive** (`"sitcom"`, `"SITCOM"`, and `"Sitcom"`
all accept) and the stored value is always the vocabulary's canonical casing
shown above — so the casing you read back on [serve responses](/guides/catalog-serve#item-fields) is canonical regardless of the
casing you submitted. Format labels (`"series"`, `"limited series"`) and
editorial judgment calls are **your** mapping to make on the producer side —
an unknown token is rejected (`ingest.unknown_genre`), never reinterpreted.

### Rating vocabulary \[#ratings]

The accepted content-rating tokens, matched case-insensitively:

* **US film & TV scales:** `G`, `PG`, `PG-13`, `R`, `NC-17`, `TV-Y`, `TV-G`,
  `TV-PG`, `TV-14`, `TV-MA`
* **International:** `U`, `U/A`, `13+`, and the numeric scale `12`, `12+`,
  `15`, `18`

How each token places against the age tiers — `12`/`12+` in the Tween band,
`15`/`18` adult-visible only — is defined in the [age-tier model](/guides/sessions#age-tiers).

> **Note**
>
> **Rating variants are your mapping to make.** A source scale that isn't in
> the vocabulary — `PG-15`, `A (Adult)`, `MA`, an ESRB code — must be mapped
> to its vocabulary equivalent (`15`, `18`, `TV-MA`, …) in **your** feed
> before submission. The platform rejects unrecognized tokens
> (`ingest.unknown_rating`); it never normalizes or reinterprets them for
> you. Where no faithful mapping exists, send `null` — it fails closed for
> minors rather than guessing.

> **Warning**
>
> **Send `null` for unknown, never a placeholder.** An empty string, or a
> sentinel like `"OTHER"` / `"UNKNOWN"` / `"N/A"`, is rejected with
> `ingest.sentinel_value` — it is not silently normalized away. A data gap
> reported as a gap is usable; a placeholder that looks like data is not.

### All four kinds ingest end to end

`Movie`, `TV`, `Audio`, and `Game` titles all ingest, and audio and game
titles are searchable and servable once they land.

> **Note**
>
> **Audio and game ratings are a known gap.** The [rating vocabulary](#ratings) carries no audio or game scale (no ESRB/PEGI-style
> tokens), and the rating check is **kind-agnostic** — the platform never
> normalizes or converts a rating for you. For an audio or game title that
> means, exactly:
>
> * **`null`** is accepted and stored as null — and a title with no content
>   rating is treated as most-restrictive by the [age gate](/guides/sessions#age-tiers), so it is **hidden from `Child`,
>   `Tween`, and `Unknown` members** and fully visible to `Adult` members.
>   Send `null`; the fail-closed outcome is the designed one.
> * A **vocabulary token** (`"PG"`, say) is accepted and applied verbatim —
>   age-gated exactly as it would be on a movie.
> * An **unrecognized token** (an ESRB code, say) is rejected per item as
>   `ingest.unknown_rating` — never silently nulled.
>
> Plan your UI for the fail-closed default rather than expecting parity with
> movie and TV until a real audio/game rating vocabulary lands.

## Retrying safely: idempotency and correlation ids \[#retrying-safely]

Two headers matter on a retry, and they behave in **opposite** ways. Getting
this backwards is the most common ingestion mistake, so it is worth stating
as a rule: **one idempotency key per batch, one correlation id per attempt.**

* **`Idempotency-Key`** (optional) — a caller-chosen key that makes a retried
  submission safe. **Reuse it on every attempt of the same batch.** A repeat
  with the same key resolves to the *same job* rather than ingesting twice.
  With no key sent, identical content is deduplicated instead.
* **[`X-Correlation-Id`](/guides/errors#correlation-ids)** (optional) —
  **mint a fresh one on every attempt.** It is a per-request cost-reservation
  key, not a request id you may replay. Reusing the correlation id of an
  attempt whose job already finished earns a **`429` ceiling-exceeded
  response** — a confusing denial for a submission that actually succeeded.

Reusing the correlation id is what a client naturally does on retry, which is
exactly why this needs saying: keep the idempotency key, change the
correlation id.

## Polling the job

`GET /v1/catalog/ingest/jobs/{jobId}` returns:

| Field                | Meaning                                                                                                       |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `status`             | `"queued"` → `"running"` → `"completed"` or `"failed"`. While queued or running, only `status` is meaningful. |
| `report`             | The full outcome report — **completed jobs only**.                                                            |
| `mappings[]`         | Per-title `externalTitleId` → `titleId` outcomes. Empty until completion.                                     |
| `fieldConflictCount` | How many submitted values were **not applied** (see below).                                                   |
| `error`              | An actionable failure description — **failed jobs only**.                                                     |

A job id that is not your airline's returns `404 ingest.job_not_found` —
airline-scoped, never a leak of another tenant's job.

### The report

| Field                                                                                                    | Meaning                                                                                                                                                                                                                                                                                                             |
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `submittedCount` / `acceptedCount` / `rejectedCount`                                                     | Straight counts of what came in and what happened.                                                                                                                                                                                                                                                                  |
| `unclassifiedHeldCount`                                                                                  | Titles that could not be placed into a rights namespace — held, neither accepted nor rejected. **Always `0` on this endpoint**: your titles are placed directly into your own airline's namespace, so a partner submission is never held. The field is shared with our internal batch path, where it is meaningful. |
| `genreCoveragePct`, `castCoveragePct`, `synopsisCoveragePct`, `runtimeCoveragePct`, `keywordCoveragePct` | Percentages on the **0–100 scale** (42.5 means 42.5%), computed over **accepted** submissions only. When nothing was accepted every percentage is `0.0`, never a non-numeric value.                                                                                                                                 |
| `rejections[]`                                                                                           | One entry per rejected item: `{ externalTitleId, code, message }`.                                                                                                                                                                                                                                                  |
| `producerId`, `manifestVersion`                                                                          | Run provenance. On this endpoint they are the fixed labels `"partner-http"` and `"direct"` — the partner ingest path, placing titles directly in your namespace.                                                                                                                                                    |

Low coverage is a real signal, not noise: semantic ranking quality is bounded
by the metadata you send. `keywordCoveragePct` in particular drives
[franchise and entity queries](/guides/search#entities).

### Dispositions

Each entry in `mappings[]` says how the mapping came to be:

* **`created`** — a new platform title was minted.
* **`linked`** — the submission matched a title we already had, by exact
  normalized fingerprint (kind + normalized title + year). There is no fuzzy
  auto-merge, ever.
* **`updated`** — an already-mapped title whose **missing** fields were
  filled.

### Field conflicts: fill-nulls-only

Ingestion **fills missing fields; it never overwrites existing ones.** When a
submitted value differs from an existing non-empty platform value on a linked
title, the submitted value is **not applied** — the conflict is counted in
`fieldConflictCount` and sampled in the report rather than silently resolved
in either direction.

Artwork follows the same fill-missing rule **per member**. If a stored title already has a
`posterUrl`, a different incoming poster is withheld and counted as a conflict, while a missing
stored `heroUrl` can still be filled from the same submission.

So a non-zero `fieldConflictCount` is not an error; it means your submission
and our stored record disagree about titles you linked to, and you may want
to look at which.

### Per-item rejection codes

Rejections are **per item**: a bad item does not sink its siblings. The
`code` is the stable contract; the `message` is human-readable prose saying
what was wrong and what to send instead — safe to log and to surface to an
operator.

**Field-level**

| Code                          | Meaning                                                                                                                                                                                                       |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ingest.unknown_kind`         | `kind` outside `Movie` / `TV` / `Audio` / `Game` (case-sensitive).                                                                                                                                            |
| `ingest.unknown_genre`        | A `genres` entry outside the vocabulary above.                                                                                                                                                                |
| `ingest.unknown_rating`       | A non-null `rating` outside the known content-rating scale.                                                                                                                                                   |
| `ingest.year_out_of_range`    | A non-zero `year` outside 1900–2100. (`0`, the unknown-year value, is never rejected by this code.)                                                                                                           |
| `ingest.runtime_out_of_range` | A non-null `runtimeMinutes` that is not strictly positive.                                                                                                                                                    |
| `ingest.score_out_of_range`   | A review score outside the (0, 10] bound.                                                                                                                                                                     |
| `ingest.invalid_artwork_url`  | A supplied `posterUrl` or `heroUrl` is empty, relative, non-HTTPS, over 2,048 characters, contains whitespace, or has no valid host. The platform does not check whether an otherwise valid URL is reachable. |
| `ingest.sentinel_value`       | A placeholder value where `null` was required (see the warning above).                                                                                                                                        |

**Identity resolution**

| Code                              | Meaning                                                                                                                                                                                                                                                |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ingest.ambiguous_match`          | Two or more existing titles share the submission's fingerprint. It is **neither linked nor created** — a coin-flip merge and a third duplicate are both worse than a loud rejection. Resolve the duplicate titles, then resubmit.                      |
| `ingest.duplicate_in_batch`       | The same `externalTitleId` appeared twice in one batch. The **first occurrence in submission order** is processed; later ones are rejected. Deterministic, never last-wins.                                                                            |
| `ingest.inconsistent_platform_id` | A supplied `titleId` disagrees with platform state — it doesn't exist, its stored identity contradicts your title/kind/year, or it contradicts the mapping already recorded for that external id. Omit the platform id (it is optional) or correct it. |
| `ingest.dangling_mapping`         | A recorded mapping points at a title that **was written and no longer exists**.                                                                                                                                                                        |

> **Note**
>
> **`ingest.dangling_mapping` never means "an earlier attempt didn't
> finish".** That case — a batch interrupted between recording a mapping and
> storing the title — is recovered automatically when you resubmit, and is
> never reported under this code. This code always means a title went missing
> after the fact, so the remedy is different: investigate the missing title,
> then resubmit. Resubmitting alone will not fix it.

### An external id is resolvable only once its batch completes \[#resolvable]

An external id becomes resolvable — to the [flight active-catalog push](/guides/flights#active-catalog), for example — when the batch that
submitted it **completes**, not when that batch is accepted. An id from an
interrupted job reports as unresolved until the batch is resubmitted and
finishes.

Read "unresolved" as **"this batch has not finished"**, and nothing more
specific:

* It does *not* reliably mean the title has not been written. A submission
  that **linked** to a title you already had leaves that title present and
  fully servable — and its external id still reports unresolved until the
  batch completes.
* The platform will not report an id resolvable while any doubt remains.
* The remedy is the same either way: **resubmit the ingest.** Resubmission
  resumes; it does not duplicate.

## Request rate limits

The partner surfaces carry per-client request-rate limits, and **submission
and status polling are separate classes with separate budgets**:

* Job **submission** has its own per-client window, sized like the other
  partner write routes (the [flight endpoints](/guides/flights)).
* Job **status polling** has its own, **higher** per-client window.

The partition is the point: a submission burst can never blind your status
polling, and a status sweep can never consume your submission headroom. Both
are deliberately generous — sized so that legitimate operational bursts (a
full-catalog resync submitting a batch train while sweeping every job's
status) never see a `429`.

Over the limit, either endpoint returns **`429 rate.limited`** in the
standard [error envelope](/guides/errors) with a **`Retry-After`** header.
Honor it and back off. The numeric limits are deployment-tuned and are never
disclosed in the response — `Retry-After` is the only retry hint you need.

> **Warning**
>
> **`429` has two distinct causes on the submission endpoint, with two
> different bodies.** Branch on the **body shape**, not on the status code:
>
> * **Usage ceiling** — the ceiling-exceeded body:
>   `{ "schemaVersion", "reason", "ruleId", "currentSpendUsd", "ceilingUsd", "resetAtUtc", "correlationId" }`.
>   `resetAtUtc` tells you when the window clears. There is no `Retry-After`
>   header, and retrying sooner will not help.
> * **Per-client request rate** — the standard error envelope with
>   `"code": "rate.limited"` and a `Retry-After` header.
>
> The API Reference can only declare one schema per status code, so it
> documents the ceiling shape; this is the case it cannot express.

## Planned capabilities

> **Note**
>
> **Catalog replace mode — planned.** A future optional `mode` property on
> the ingest job will let you reconcile your **full** catalog against a
> complete submission rather than upserting into it: batches accumulate in
> one job session, an explicit commit applies the result atomically, and
> titles absent from the submission are **deactivated, never deleted** —
> identifiers, external-id mappings, and personalization history are all
> preserved.
>
> The compatibility promise, so you can plan around it: **`mode` is strictly
> opt-in and additive.** Omitting it keeps exactly today's upsert behavior,
> unchanged. And because deactivation preserves history, a full monthly
> catalog refresh never cold-starts recommendations, and no intermediate
> state ever serves an empty or partial catalog.
>
> We are not publishing request or response shapes or a delivery date for
> this yet.

## Errors

Standard [envelope](/guides/errors): `422 request.validation` (bad
`sourceSystem`, empty `items`, an item missing `externalTitleId` / `kind` /
`title`), `422 ingest.batch_too_large` (over 500 items),
`404 ingest.job_not_found` (an unknown or foreign job id),
`429` (see the two causes above), `401` / `403`,
`413 request.malformed` (body over 8 MiB), `500`.

Full request and response schemas are in the
[API Reference](/api-reference).