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

# Product Ingestion

`POST /v1/products/ingest/jobs` submits a batch of products for ingestion
into your airline's product corpus; `GET /v1/products/ingest/jobs/{jobId}`
reports what happened to it. This is the **product sibling of [catalog ingestion](/guides/catalog-ingest)** — the same asynchronous
accept-then-poll flow, the same envelope family, and the same retry
discipline, applied to what you sell rather than what you screen.

Both endpoints are **machine-to-machine**. They take no `X-Session-Id`, and
your **airline is resolved from your credential** — never a field on the
request.

What is deliberately the same as catalog ingestion:

* **The flow** — `202 { jobId, status: "queued" }` on submit, then poll for
  `queued` → `running` → `completed` / `failed`.
* **The limits** — 1–500 items per batch (`422 ingest.batch_too_large`
  above it, split and resubmit; never partially ingested), 8 MiB body cap
  (`413 request.malformed`).
* **The retry rule** — [one `Idempotency-Key` per batch, one `X-Correlation-Id` per attempt](/guides/catalog-ingest#retrying-safely).
* **`sourceSystem`** — 1–64 lowercase letters, digits, and hyphens; your id
  namespace, scoping every `externalProductId` in the batch.

What is different — money rules, update semantics, and the product reading
of the shared status shape — is this page.

## Quickstart: ingest, then poll

**1. Submit a batch.**

```bash
curl -X POST "https://api.staging.westiq.ai/v1/products/ingest/jobs" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: products-2026-08-batch-001" \
  -H "X-Correlation-Id: req_0197c0a1b2c3d4e5f60718293a4b5c6d" \
  -d '{
        "sourceSystem": "partner-dutyfree",
        "items": [
          {
            "externalProductId": "DF-1001",
            "name": "Silk Travel Scarf",
            "category": "Fashion",
            "description": "Hand-rolled silk scarf in a travel pouch.",
            "price": 49.99,
            "currency": "USD",
            "brand": "Maison Verre",
            "genderTarget": "unisex",
            "giftIntent": true,
            "tags": ["silk", "duty-free"],
            "destinationTags": ["beach"],
            "contentAffinities": ["Romance"],
            "artwork": {
              "imageUrl": "https://media.example.com/products/DF-1001.webp",
              "heroUrl": "https://media.example.com/products/DF-1001-hero.webp"
            }
          }
        ]
      }'
```

`202 Accepted`:

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

**2. Poll the job.**

```bash
curl "https://api.staging.westiq.ai/v1/products/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": 0,
    "castCoveragePct": 0,
    "synopsisCoveragePct": 0,
    "runtimeCoveragePct": 0,
    "keywordCoveragePct": 0,
    "rejections": []
  },
  "mappings": [
    { "externalTitleId": "DF-1001", "titleId": 10000217, "disposition": "created" }
  ],
  "fieldConflictCount": 0
}
```

In `mappings[]`, **`externalTitleId` carries the external *product* id you
submitted, and `titleId` is the platform *product* id** — the status body is
the shared ingest shape, and these are its field names on both surfaces.
The platform product id is an opaque integer we assign; don't infer anything
from its size or range. (Why the report's percentages read `0` is
[below](#the-report-read-for-products).)

## Submitting a batch

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

> **Warning**
>
> **One currency per batch.** A batch mixing two or more well-formed
> currencies is rejected **whole** at submission — `422
>   ingest.mixed_currency` — never converted or averaged. Split your feed by
> currency and resubmit. An *ill-formed* currency on one item does **not**
> condemn the batch: that rejects per item in the report
> (`ingest.invalid_currency`) and its siblings still land.

### Item fields

| Field               | Required  | Rules                                                                                                                                                                                                                                                                                             |
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalProductId` | yes       | Your id for this product, within `sourceSystem`. Non-blank. This is the identity later submissions **update** under.                                                                                                                                                                              |
| `name`              | yes       | Display name. Non-blank.                                                                                                                                                                                                                                                                          |
| `category`          | yes       | One of the canonical product categories, **exact casing**: `Fashion`, `Fragrance`, `Beauty`, `Accessories`, `Electronics`, `Travel Essentials`, `Food & Beverage`, `Local & Destination`, `Kids & Family`. Unknown → `ingest.unknown_category`, never guessed into a nearby one.                  |
| `price`             | in effect | The catalog price, in the currency's **main unit**. `null` → `ingest.missing_price` — a product with no known price is not sellable data, and it is **never defaulted to 0**. See [money rules](#money-rules) for the bounds.                                                                     |
| `currency`          | in effect | Three-letter **uppercase** ISO 4217 code (`"USD"`). Absent or ill-formed → `ingest.invalid_currency`.                                                                                                                                                                                             |
| `description`       | no        | Free text. Captured and stored for upcoming use — it is not returned by any read surface yet, so don't build UI on it.                                                                                                                                                                            |
| `minAge`            | no        | Minimum passenger age this product may be shown to (`18` for alcohol). `null` = no age floor. Negative → `ingest.min_age_out_of_range`. Enforced by the platform's age gating on every surface that shows products.                                                                               |
| `brand`             | no        | Brand name. `null` = unbranded or not supplied.                                                                                                                                                                                                                                                   |
| `genderTarget`      | no        | Exactly `"male"`, `"female"`, `"unisex"`, or `null` — lowercase. `null` and `"unisex"` both mean unrestricted. Anything else → `ingest.unknown_gender_target`.                                                                                                                                    |
| `recipient`         | no        | Declared gift-recipient affinity (`"partner"`, `"child"`, `"colleague"`). `null` = unrestricted.                                                                                                                                                                                                  |
| `giftIntent`        | no        | `true` when the item is commonly purchased as a gift — feeds gift-intent search and curation.                                                                                                                                                                                                     |
| `tags`              | no        | Free-form descriptive tokens (`["silk", "duty-free"]`) used for matching and display.                                                                                                                                                                                                             |
| `destinationTags`   | no        | Destination-appeal tokens — climate/culture/activity (`["beach", "cold-weather"]`).                                                                                                                                                                                                               |
| `contentAffinities` | no        | Content genres this product's appeal correlates with, from the [content-genre vocabulary](/guides/catalog-ingest#genres) — matched case-insensitively, stored in canonical casing, and matched against a member's own genre affinity. Unknown → `ingest.unknown_genre`.                           |
| `artwork`           | no        | Optional `{ imageUrl?, 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. |

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.

`price` and `currency` are "required in effect": the schema admits `null`
so that the *rejection* can be per-item and loud, but a null price or a
missing currency never ingests.

> **Warning**
>
> **Send `null` for unknown, never a placeholder.** Exactly as on [catalog ingestion](/guides/catalog-ingest): an empty string or a sentinel like
> `"OTHER"` / `"UNKNOWN"` / `"N/A"` — in `description`, `brand`,
> `recipient`, `tags`, or `destinationTags` — is rejected per item with
> `ingest.sentinel_value`, not silently normalized away.

## Money rules \[#money-rules]

Price and currency are **catalog data**: everything downstream quotes the
price you ingested — nothing invents, converts, or adjusts it. That is why
the gates below reject rather than repair:

* **Main units, never minor units.** Send `49.99`, not `4999`. The ceiling —
  `9999999999.99`, with negatives rejected too (`ingest.price_out_of_range`)
  — exists because a figure that size is almost always the classic ×100
  minor-units feed bug, and it **rejects rather than being clamped**.
* **At most two decimal places.** A finer value → `ingest.price_too_precise`,
  never rounded — a silently altered price is a defect you would otherwise
  find only at reconciliation.
* Both are **per-item** gates: one bad price is one line in the report, and
  the rest of the batch still lands.

> **Note**
>
> **Known limitation — three-decimal currencies.** Two decimal places means
> the three-decimal ISO 4217 currencies (`KWD`, `BHD`, `OMR`) cannot be
> submitted today: those items are rejected rather than served a rounded
> price. This is a current limitation, stated plainly — not a scheduled
> feature.

## Re-submission updates — and every field you send wins \[#updates]

Product ingestion is **partner-authoritative**, and this is the one place
its behavior is deliberately the opposite of catalog ingestion's, because
the two corpora have different owners:

* **Catalog** ingestion fills missing fields only and never overwrites —
  the platform enriches titles, so a differing incoming value is withheld
  and counted in `fieldConflictCount`.
* **Product** ingestion treats *you* as the authority on your own goods.
  Re-submitting a product under the same `externalProductId` **updates
  it**: a supplied value replaces the stored one (a price change lands), an
  omitted optional field keeps its stored value (a price correction never
  wipes the brand). `fieldConflictCount` is therefore **always `0` on
  product jobs** — nothing is ever withheld. Artwork is updated per member: a supplied
  `imageUrl` or `heroUrl` replaces that stored member, while an omitted member preserves it.

> **Warning**
>
> **Re-submit from current state, never from a cached copy — every required
> field you send overwrites.** `name`, `category`, `price`, and `currency`
> are required on every submission, so there is no such thing as a
> price-only update: correcting a price re-sends all four, and **every one
> of them wins**. Re-pushing from a stale local copy silently regresses the
> name and category to the stale values — and there is no warning signal,
> because `fieldConflictCount` is `0` by design.

One bounded limitation beside it: an optional value, once set, is
**updateable but not removable**. Omitting it — or sending `null`, `false`,
or an empty list — preserves the stored value; re-submission updates
values, it does not clear them. This is the deliberate non-destructive
trade: an inexpressible "clear" beats a partial submission silently wiping
data. A removal capability would be announced separately if it ships; a
full corpus reset is the planned [replace-mode capability](/guides/catalog-ingest#planned-capabilities).

### Dispositions, read for products

* **`created`** — a new platform product was minted.
* **`linked`** — a re-submission that was **value-identical** to what is
  stored; nothing changed.
* **`updated`** — a re-submission changed stored values.

## Retrying safely

The [catalog-ingest retry rule](/guides/catalog-ingest#retrying-safely)
applies verbatim: **one `Idempotency-Key` per batch, one fresh
`X-Correlation-Id` per attempt.** A repeat with the same key resolves to the
same job; a reused correlation id on a finished job earns a confusing `429`.

One scoping note: the idempotency key dedupes **within one ingest surface**.
The same key on a catalog `POST` and a product `POST` yields two independent
jobs, each poll-able on its own status route. Keep "one key per batch" and
this never matters.

## Polling the job

`GET /v1/products/ingest/jobs/{jobId}` returns the same status shape as
catalog ingestion: `status`, `report` (completed jobs), `mappings[]`,
`fieldConflictCount`, `error` (failed jobs).

A job id that is not your airline's returns `404 ingest.job_not_found` —
and so does a **catalog** job id polled on this route (or a product job id
on the catalog route). Each surface reads only its own jobs; poll a job on
the surface that accepted it.

### The report, read for products \[#the-report-read-for-products]

The report is the **shared ingest report shape**, and several of its fields
describe catalog-title concepts that products do not carry. On product jobs,
read it like this:

| Field                                                                                                    | On product jobs                                                                                                                                                                                  |
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `submittedCount` / `acceptedCount` / `rejectedCount`                                                     | Meaningful — straight counts, as on catalog.                                                                                                                                                     |
| `rejections[]`                                                                                           | Meaningful — `{ externalTitleId, code, message }` per rejected item, where `externalTitleId` carries your external **product** id.                                                               |
| `genreCoveragePct`, `castCoveragePct`, `synopsisCoveragePct`, `runtimeCoveragePct`, `keywordCoveragePct` | **Always `0`.** These measure catalog-title fields (genres, cast, synopsis, runtime, keywords) that products do not carry — inert here, not a signal about your feed's quality.                  |
| `unclassifiedHeldCount`                                                                                  | **Always `0`.** No licensing/rights ruleset applies to products — the corpus is your own, so nothing is ever held for classification.                                                            |
| `producerId`, `manifestVersion`                                                                          | The fixed labels `"partner-http"` and `"direct"`. `manifestVersion`'s documented meaning is catalog-rights provenance, which doesn't apply to products — read both as inert run provenance here. |

## Per-item rejection codes

Same contract as catalog ingestion: rejections are **per item** (a bad item
never sinks its siblings), `code` is the stable value to branch on, and
`message` is actionable prose safe to log and show an operator.

| Code                           | Meaning                                                                                                                                                                                                                                                                                                                                                        |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ingest.missing_price`         | `price` was `null`/absent. Send the real catalog price; it is never defaulted to `0`.                                                                                                                                                                                                                                                                          |
| `ingest.price_out_of_range`    | `price` negative, or above `9999999999.99` — almost always a minor-units (×100) feed bug. Send main units.                                                                                                                                                                                                                                                     |
| `ingest.price_too_precise`     | `price` finer than two decimal places. Never rounded for you.                                                                                                                                                                                                                                                                                                  |
| `ingest.invalid_currency`      | `currency` absent or not a three-letter uppercase ISO 4217 code.                                                                                                                                                                                                                                                                                               |
| `ingest.unknown_category`      | `category` outside the canonical set above (exact casing).                                                                                                                                                                                                                                                                                                     |
| `ingest.unknown_genre`         | A `contentAffinities` entry outside the [content-genre vocabulary](/guides/catalog-ingest#genres).                                                                                                                                                                                                                                                             |
| `ingest.unknown_gender_target` | `genderTarget` outside `"male"` / `"female"` / `"unisex"` / `null` (lowercase).                                                                                                                                                                                                                                                                                |
| `ingest.min_age_out_of_range`  | A negative `minAge`. `null` (no age floor) or `0`+ are valid.                                                                                                                                                                                                                                                                                                  |
| `ingest.invalid_artwork_url`   | A supplied `imageUrl` 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 (`""`, `"OTHER"`, `"UNKNOWN"`-shaped) where `null` was required.                                                                                                                                                                                                                                                                                 |
| `ingest.duplicate_in_batch`    | The same `externalProductId` twice in one batch — the first occurrence in submission order is processed; later ones are rejected.                                                                                                                                                                                                                              |
| `ingest.dangling_mapping`      | A recorded mapping points at a product that was written and no longer exists — [same meaning, same narrow-by-construction framing as on catalog ingestion](/guides/catalog-ingest#per-item-rejection-codes): it never means "an earlier attempt didn't finish" (that case recovers automatically on resubmit). Investigate the missing product, then resubmit. |

The batch-level `422`s — `ingest.mixed_currency`, `ingest.batch_too_large`
— and the `404 ingest.job_not_found` are HTTP-level errors, not report
entries. See [Errors & limits](/guides/errors).

## Request rate limits

The product-ingest routes carry their own per-client limits, in the same
split as catalog ingestion: **submission and status polling are separate
classes with separate budgets**, the status class deliberately the more
generous — a status sweep can never starve your submits. Over a limit:
`429 rate.limited` with `Retry-After`. The numeric limits are
deployment-tuned, not a contract; honor `Retry-After`.

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

## Errors

Standard [envelope](/guides/errors): `422 request.validation` (bad
`sourceSystem`, empty `items`, an item missing `externalProductId` /
`name` / `category`), `422 ingest.batch_too_large` (over 500 items),
`422 ingest.mixed_currency` (two or more well-formed currencies in one
batch), `404 ingest.job_not_found` (unknown, foreign, or other-surface 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).