Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.
The Content API is how your system pushes catalog content to Luigi’s Box: you send objects as they are created and changes as they happen, instead of publishing a file for Luigi’s Box to fetch on a schedule.
It is a complete way to run a catalog, not a supplement to one. If your platform can emit what changed, use the Content API: the whole catalog moves through one path, a price change is searchable in seconds rather than on the next fetch, and there is nothing to reconcile.
Feeds are the alternative for platforms that already produce catalog exports and would rather not build a push integration.
Mapping applies here too
Section titled “Mapping applies here too”Content pushed over this API goes through the same field mapping as content arriving from a feed, resolved by object type. So you can send your own field names and shapes here as well — you are not obliged to do the renaming and type conversion yourself before posting.
If your catalog has no mapping for a type, objects are stored as sent.
The three operations
Section titled “The three operations”One path, three verbs:
POST /index/v1/{catalog_id}/index/ Upsert whole objectsPATCH /index/v1/{catalog_id}/index/ Change named fieldsDELETE /index/v1/{catalog_id}/index/ Remove objectsAll three take a batch in {"objects": [...]} and answer with the status that sums up the
batch:
| Status | Meaning |
|---|---|
202 Accepted | Every object was accepted. |
207 Multi-Status | Some objects were accepted and some were not; rejected_objects lists the ones that were not. |
422 Unprocessable Content | Nothing was applied: the body was malformed, or every object in it was rejected. |
503 Service Unavailable | Nothing was wrong with the objects, but the batch could not be processed. Part of it may have been applied; sending it again unchanged is safe. Send it again after Retry-After seconds. |
{ "accepted_count": 2, "rejected_count": 0, "rejected_objects": [] }207 is a success status, so raise_for_status(), response.ok and any other check for
2xx pass on it. A 207 means some objects were not applied: read rejected_objects on
every 2xx response. See Batching.
202 and 207 do not mean searchable: indexing is asynchronous.
Every call needs a token for the https://api.<region>.luigisbox.ai/index audience, and a
grant on the catalog — writing and deleting are granted separately, so an integration that
only ever upserts need not be able to delete. See
Server-to-server tokens.
Upsert
Section titled “Upsert”POST sends complete objects. The stored object becomes exactly what you send — fields you
leave out are removed, not preserved. This is the operation to use when your system can
produce the whole record.
curl -X POST 'https://api.eu1.luigisbox.ai/index/v1/lbc_8w3k2p/index/' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{ "objects": [ { "@id": "product/sku-1001", "@type": "product", "@title": "Blue Cotton T-Shirt", "@category": ["category/shirts"], "@brand": "brand/northwear", "@group_id": "northwear-t-shirt-1001", "@group_primary": true, "url": "https://example.com/products/blue-cotton-t-shirt", "image_url": "https://cdn.example.com/products/sku-1001.jpg", "price": 29.9, "list_price": 39.9, "availability": 1, "color": "blue", "size": "M", "private:margin": 8.4 } ] }'@id, @type and @title are required on every object you upsert, and the type prefix in
@id must match @type. Send @title directly, or let the catalog’s mapping produce it
from one of your attributes. An object whose @title is missing, null or blank after
mapping is rejected on its own with title_missing. Everything else is optional: registered
@ fields, your own attributes, and private: fields. Registered fields are read only under
their @ name: unless your catalog’s mapping renames it, an attribute named title, type
or group_id is stored as your own attribute. See the object model for the full registry.
A category may also declare @aliases,
further names that @parent and @category can reference it by.
Partial updates
Section titled “Partial updates”PATCH changes named fields and leaves everything else alone. This is what a price or
stock update should use — it is smaller, it cannot accidentally drop attributes, and it
does not require your system to be able to rebuild the whole record.
curl -X PATCH 'https://api.eu1.luigisbox.ai/index/v1/lbc_8w3k2p/index/' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{ "objects": [ { "@id": "product/sku-1001", "@type": "product", "price": 24.9, "availability": 1 }, { "@id": "product/sku-2087", "@type": "product", "availability": 0 } ] }'The rules:
| You send | Result |
|---|---|
| A field with a value | Set to that value |
| A field omitted | Left unchanged |
"@group_id": null | The product leaves its variant group |
Only @id and @type | Rejected — a partial update must name at least one field |
A partial update with no fields to update is rejected with 422.
Two variant-specific rules follow from “omitted means unchanged”:
- To demote a primary product, send
"@group_primary": falseexplicitly. Omitting the field leaves it primary. @group_primarycan be changed without restating an unchanged@group_id.
See Variant groups.
Delete
Section titled “Delete”DELETE takes identities only:
curl -X DELETE 'https://api.eu1.luigisbox.ai/index/v1/lbc_8w3k2p/index/' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{ "objects": [ { "@id": "product/sku-1001", "@type": "product" }, { "@id": "product/sku-2087", "@type": "product" } ] }'Deleting an object that is not there is not an error. Deleting a product does not touch the categories or brands it referenced — references do not cascade.
Batching
Section titled “Batching”Send objects in batches. One request with 500 price changes costs one round trip and one rate-limit entry; the indexing work is the same as for 500 single-object requests.
import time
def send_batches(client, catalog_id, objects, batch_size=500): """POST objects to the Content API in batches.
Returns every rejection, with `index` pointing into `objects`. Raises on a batch whose body is malformed, and on one that cannot be processed after three attempts. """ rejected = [] for start in range(0, len(objects), batch_size): batch = objects[start : start + batch_size] for attempt in range(3): response = client.post( f"https://api.eu1.luigisbox.ai/index/v1/{catalog_id}/index/", json={"objects": batch}, ) if response.status_code != 503 or attempt == 2: break time.sleep(int(response.headers.get("Retry-After", "5"))) if response.status_code == 422: details = response.json()["exception_details"] if "rejected_objects" in details: rejected.extend({**r, "index": start + r["index"]} for r in details["rejected_objects"]) continue response.raise_for_status() # 202 and 207 both land here; a 207 lists what was not applied. rejected.extend({**r, "index": start + r["index"]} for r in response.json()["rejected_objects"]) return rejectedA batch is checked in two steps.
The request body is checked first, and all-or-nothing: if an object in it is malformed — no
@id, no @type — the whole request is rejected with 422 and nothing in it is queued.
Read exception_details.validation_errors; the loc path includes the object’s index, so
you can point at the offending record.
Each object is then checked on its own. An object that fails — a value whose type conflicts
with the field’s established type, an unrecognized @ field — is rejected, and the rest of
the batch is accepted. The response is 207, and rejected_objects names every rejected
object with a code and a message:
{ "accepted_count": 1, "rejected_count": 1, "rejected_objects": [ { "index": 1, "@id": "product/sku-2087", "@type": "product", "code": "field_type_conflict", "message": "Type conflict for field 'price': existing type is 'float', but got value of type 'string'" } ]}Switch on code; it is stable. message is for people and may be reworded. When an object
has several problems, code names the first one found and message describes the first ten
and counts the rest. Field types are checked only once nothing else is wrong with the object, so fixing
one rejection can reveal a type conflict. The codes are listed in
What gets rejected.
When every object in the batch is rejected, nothing is applied and the response is 422
instead, with the same list in exception_details:
{ "reason": "No object in the batch was accepted; see exception_details.rejected_objects for why", "exception_details": { "rejected_count": 1, "rejected_objects": [ { "index": 0, "@id": "product/sku-2087", "@type": "product", "code": "field_type_conflict", "message": "Type conflict for field 'price': existing type is 'float', but got value of type 'string'" } ] }, "request_id": "01JQ8H3Z9K2F7M4N6P8R0S2T4V"}exception_details carries either rejected_objects or validation_errors, never both.
A rejected object is not applied. Fix it and send it again. index is its position in the
objects array you sent, so two objects in one batch that share an @id are told apart. A
rejection with code internal_error is not about the object: send it again unchanged,
later. When every object in a batch is rejected with internal_error, the response is 503
with a Retry-After header rather than 422; send the whole batch again after that many
seconds.
Send batches for one catalog in order. Two updates to the same object in flight concurrently can be applied in either order.
What gets rejected
Section titled “What gets rejected”| Cause | What is rejected | code |
|---|---|---|
Missing @id or @type | The whole batch, 422 | — |
| A partial update with no fields | The whole batch, 422 | — |
A malformed @title, @group_id or @group_primary — a @title that is a number or another non-string, a null or blank @title on a partial update, an empty @group_id, @group_primary: true without a @group_id on an upsert | The whole batch, 422 | — |
A missing, null or blank @title on an upsert, after mapping | The object | title_missing |
@id is not <type>/<value> with both parts non-empty | The object | id_invalid |
@id prefix does not match @type — item/123 sent as a product | The object | id_type_mismatch |
An @ field that only Luigi’s Box sets, such as @updated_at | The object | reserved_field_read_only |
An unrecognized @ field | The object | reserved_field_unknown |
A field in a namespace Luigi’s Box owns: lbx:, signals:, ml: or lexical: | The object | namespace_forbidden |
A field in any other namespace except private: | The object | namespace_unknown |
| A value whose type conflicts with the field’s established type | The object | field_type_conflict |
| An array whose elements are not all of one type | The object | array_mixed_types |
@category or @aliases that is not a list of category/<value> references, or @aliases on an object that is not a category | The object | category_reference_invalid |
@group_id or @group_primary that is malformed after mapping, or set on an object that is not a product | The object | variant_group_invalid |
| A field name or value containing a null character | The object | value_invalid |
| The catalog’s mapping cannot be applied to the object | The object | mapping_failed |
| An object larger than about 1 MB after mapping | The object | object_invalid |
| Any other malformed object | The object | object_invalid |
| An internal failure; nothing is wrong with the object | The object | internal_error |
A rejected object is listed in rejected_objects while the rest of its batch is accepted,
under 207. A batch in which every object is rejected answers 422, or 503 when every
rejection is internal_error — see Batching. The set of codes is fixed; adding
one is announced as a change to the API.
The first value a field ever receives fixes its type for the catalog. If price arrives as
29.9 it is numeric from then on; a later "29.90" as a string is rejected rather than
coerced, because a field that flips between text and number breaks range filters and price
facets. Send consistent types from the start, and check a catalog’s field types with
catalog metadata.
private: is the only namespace you can write to. See
Field namespaces.
Confirming it landed
Section titled “Confirming it landed”A 207 lists the objects that were rejected; the ones a 202 or 207 accepted are not
necessarily searchable yet, so confirming that is a separate step:
- Search for it. Query the object and see whether the change is reflected.
- Read its timeline. Object history shows every change Luigi’s Box received for one object, and whether each has been applied.
See also
Section titled “See also”- Catalog object model — fields, namespaces, identity
- Object history — did my update land?
- Catalog metadata — what the catalog holds, and field types
- Feeds overview — the other way in, for platforms that already export files
- Backend quickstart
Was this page helpful?
Thanks.