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 flow —
202 { jobId, status: "queued" }on submit, then poll forqueued→running→completed/failed. - The limits — 1–500 items per batch (
422 ingest.batch_too_largeabove it, split and resubmit; never partially ingested), 8 MiB body cap (413 request.malformed). - The retry rule — one
Idempotency-Keyper batch, oneX-Correlation-Idper attempt. sourceSystem— 1–64 lowercase letters, digits, and hyphens; your id namespace, scoping everyexternalProductIdin 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.
202 Accepted:
2. Poll the job.
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
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
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, not4999. 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
externalProductIdupdates 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).fieldConflictCountis therefore always0on 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:
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.
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.