Product Ingestion

Submit your product catalog — duty-free, food & beverage, retail — in batches and poll the job for its outcome.

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 — 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 flow202 { jobId, status: "queued" } on submit, then poll for queuedrunningcompleted / 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 ruleone Idempotency-Key per batch, one X-Correlation-Id per attempt.
  • 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.

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

202 Accepted:

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

2. Poll the job.

$curl "https://api.staging.westiq.ai/v1/products/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": 0,
14 "castCoveragePct": 0,
15 "synopsisCoveragePct": 0,
16 "runtimeCoveragePct": 0,
17 "keywordCoveragePct": 0,
18 "rejections": []
19 },
20 "mappings": [
21 { "externalTitleId": "DF-1001", "titleId": 10000217, "disposition": "created" }
22 ],
23 "fieldConflictCount": 0
24}

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

Submitting a batch

FieldRequiredRules
sourceSystemyes1–64 characters of lowercase letters, digits, and hyphens. Anything else → 422 request.validation.
itemsyes1–500 entries. Empty → 422 request.validation; over 500 → 422 ingest.batch_too_large.

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

FieldRequiredRules
externalProductIdyesYour id for this product, within sourceSystem. Non-blank. This is the identity later submissions update under.
nameyesDisplay name. Non-blank.
categoryyesOne 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.
pricein effectThe catalog price, in the currency’s main unit. nullingest.missing_price — a product with no known price is not sellable data, and it is never defaulted to 0. See money rules for the bounds.
currencyin effectThree-letter uppercase ISO 4217 code ("USD"). Absent or ill-formed → ingest.invalid_currency.
descriptionnoFree text. Captured and stored for upcoming use — it is not returned by any read surface yet, so don’t build UI on it.
minAgenoMinimum 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.
brandnoBrand name. null = unbranded or not supplied.
genderTargetnoExactly "male", "female", "unisex", or null — lowercase. null and "unisex" both mean unrestricted. Anything else → ingest.unknown_gender_target.
recipientnoDeclared gift-recipient affinity ("partner", "child", "colleague"). null = unrestricted.
giftIntentnotrue when the item is commonly purchased as a gift — feeds gift-intent search and curation.
tagsnoFree-form descriptive tokens (["silk", "duty-free"]) used for matching and display.
destinationTagsnoDestination-appeal tokens — climate/culture/activity (["beach", "cold-weather"]).
contentAffinitiesnoContent genres this product’s appeal correlates with, from the content-genre vocabulary — matched case-insensitively, stored in canonical casing, and matched against a member’s own genre affinity. Unknown → ingest.unknown_genre.

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.

Send null for unknown, never a placeholder. Exactly as on catalog ingestion: 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

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.

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

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.

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.

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

FieldOn product jobs
submittedCount / acceptedCount / rejectedCountMeaningful — straight counts, as on catalog.
rejections[]Meaningful — { externalTitleId, code, message } per rejected item, where externalTitleId carries your external product id.
genreCoveragePct, castCoveragePct, synopsisCoveragePct, runtimeCoveragePct, keywordCoveragePctAlways 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.
unclassifiedHeldCountAlways 0. No licensing/rights ruleset applies to products — the corpus is your own, so nothing is ever held for classification.
producerId, manifestVersionThe 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.

CodeMeaning
ingest.missing_priceprice was null/absent. Send the real catalog price; it is never defaulted to 0.
ingest.price_out_of_rangeprice negative, or above 9999999999.99 — almost always a minor-units (×100) feed bug. Send main units.
ingest.price_too_preciseprice finer than two decimal places. Never rounded for you.
ingest.invalid_currencycurrency absent or not a three-letter uppercase ISO 4217 code.
ingest.unknown_categorycategory outside the canonical set above (exact casing).
ingest.unknown_genreA contentAffinities entry outside the content-genre vocabulary.
ingest.unknown_gender_targetgenderTarget outside "male" / "female" / "unisex" / null (lowercase).
ingest.min_age_out_of_rangeA negative minAge. null (no age floor) or 0+ are valid.
ingest.sentinel_valueA placeholder ("", "OTHER", "UNKNOWN"-shaped) where null was required.
ingest.duplicate_in_batchThe same externalProductId twice in one batch — the first occurrence in submission order is processed; later ones are rejected.
ingest.dangling_mappingA recorded mapping points at a product that was written and no longer exists — same meaning, same narrow-by-construction framing as on catalog ingestion: it never means “an earlier attempt didn’t finish” (that case recovers automatically on resubmit). Investigate the missing product, then resubmit.

The batch-level 422s — ingest.mixed_currency, ingest.batch_too_large — and the 404 ingest.job_not_found are HTTP-level errors, not report entries. See Errors & limits.

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.

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. Branch on the body shape, not the status code.

Errors

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