Catalog Ingestion

Submit your media catalog in batches and poll the job for its outcome — the accept-then-poll ingestion surface.

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.

Ingestion is the prerequisite for everything else. A title must be centrally ingested before a flight can list it in its active catalog — that push resolves external ids, it never creates titles.

Quickstart: ingest, then poll

1. Submit a batch.

$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
> }
> ]
> }'

202 Accepted:

1{
2 "schemaVersion": "1",
3 "jobId": "0197c0a1-b2c3-7d4e-8f60-718293a4b5c6",
4 "status": "queued"
5}

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

2. Poll the job.

$curl "https://api.staging.westiq.ai/v1/catalog/ingest/jobs/0197c0a1-b2c3-7d4e-8f60-718293a4b5c6" \
> -H "Authorization: Bearer $ACCESS_TOKEN"
1{
2 "schemaVersion": "1",
3 "jobId": "0197c0a1-b2c3-7d4e-8f60-718293a4b5c6",
4 "status": "completed",
5 "report": {
6 "schemaVersion": "1",
7 "producerId": "partner-http",
8 "manifestVersion": "direct",
9 "submittedCount": 1,
10 "acceptedCount": 1,
11 "rejectedCount": 0,
12 "unclassifiedHeldCount": 0,
13 "genreCoveragePct": 100,
14 "castCoveragePct": 100,
15 "synopsisCoveragePct": 100,
16 "runtimeCoveragePct": 100,
17 "keywordCoveragePct": 100,
18 "rejections": []
19 },
20 "mappings": [
21 { "externalTitleId": "CMS-88421", "titleId": 10000000, "disposition": "created" }
22 ],
23 "fieldConflictCount": 0
24}

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

FieldRequiredRules
sourceSystemyes1–64 characters of lowercase letters, digits, and hyphens (e.g. "partner-cms"). Anything else → 422 request.validation.
itemsyes1–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 — 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

FieldRequiredRules
externalTitleIdyesYour id for this title, within sourceSystem. Non-blank.
kindyesExactly "Movie", "TV", "Audio", or "Game"case-sensitive here (unlike the serve filter and search’s kindOverride, which accept any casing). Anything else → per-item ingest.unknown_kind.
titleyesNon-blank.
yearnoRelease 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.
genresnoEach entry must come from the genre vocabulary below → otherwise ingest.unknown_genre.
castnoFree-form names.
ratingnoContent rating string ("PG", "TV-14", "U/A", …). Unrecognized → ingest.unknown_rating. null = unknown.
synopsis, languagenonull = unknown. language accepts an ISO 639 code or a language name; it is not a search, filter, or matching input today.
runtimeMinutesnoWhole minutes, strictly positive. null = unknown; 0 or negative → ingest.runtime_out_of_range. Feeds time-aware search.
keywordsnoFree-form franchise/entity/theme tokens (["James Bond", "007"]) — no fixed vocabulary. These are what franchise queries match against, so they are worth sending.
imdbRatingno0–10 scale, one decimal (7.2). null = not rated. Outside (0, 10] → ingest.score_out_of_range. Feeds quality search.
rtTomatometerno0–10 scale (8.3). null = unknown. Display-first; the quality filter evaluates imdbRating.
hasAwardsnotrue 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.
titleIdnoOur 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.
rawMetadatanoString-to-string provenance bag for source fields with no first-class slot. Never read by scoring, ranking, or classification.

Genre vocabulary: Action, Adventure, Animation, Biography, Comedy, Crime, Documentary, Drama, Family, Fantasy, History, Horror, Music, Musical, Mystery, Romance, Sci-Fi, Sport, Thriller, War, Western.

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.

Audio and game ratings are a known gap. Audio and game content ratings carry no recognized scale today, so a submitted rating on those kinds normalizes to null — and a title with no content rating is treated as most-restrictive by the age gate. In practice that means audio and game titles are hidden from Child, Tween, and Unknown members until a real rating vocabulary lands. They are fully visible to Adult members. This is fail-closed by design, not a defect — but plan your UI for it rather than expecting parity with movie and TV.

Retrying safely: idempotency and correlation ids

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 (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:

FieldMeaning
status"queued""running""completed" or "failed". While queued or running, only status is meaningful.
reportThe full outcome report — completed jobs only.
mappings[]Per-title externalTitleIdtitleId outcomes. Empty until completion.
fieldConflictCountHow many submitted values were not applied (see below).
errorAn 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

FieldMeaning
submittedCount / acceptedCount / rejectedCountStraight counts of what came in and what happened.
unclassifiedHeldCountTitles 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, keywordCoveragePctPercentages 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, manifestVersionRun 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.

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.

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

CodeMeaning
ingest.unknown_kindkind outside Movie / TV / Audio / Game (case-sensitive).
ingest.unknown_genreA genres entry outside the vocabulary above.
ingest.unknown_ratingA non-null rating outside the known content-rating scale.
ingest.year_out_of_rangeA non-zero year outside 1900–2100. (0, the unknown-year value, is never rejected by this code.)
ingest.runtime_out_of_rangeA non-null runtimeMinutes that is not strictly positive.
ingest.score_out_of_rangeA review score outside the (0, 10] bound.
ingest.sentinel_valueA placeholder value where null was required (see the warning above).

Identity resolution

CodeMeaning
ingest.ambiguous_matchTwo 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_batchThe 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_idA 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_mappingA recorded mapping points at a title that was written and no longer exists.

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

An external id becomes resolvable — to the flight active-catalog push, 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).
  • 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 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.

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

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