Skip to content

    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.

    Content API

    View source

    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.

    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.

    One path, three verbs:

    POST /index/v1/{catalog_id}/index/ Upsert whole objects
    PATCH /index/v1/{catalog_id}/index/ Change named fields
    DELETE /index/v1/{catalog_id}/index/ Remove objects

    All three take a batch in {"objects": [...]} and answer with the status that sums up the batch:

    StatusMeaning
    202 AcceptedEvery object was accepted.
    207 Multi-StatusSome objects were accepted and some were not; rejected_objects lists the ones that were not.
    422 Unprocessable ContentNothing was applied: the body was malformed, or every object in it was rejected.
    503 Service UnavailableNothing 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.

    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.

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

    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.

    Terminal window
    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 sendResult
    A field with a valueSet to that value
    A field omittedLeft unchanged
    "@group_id": nullThe product leaves its variant group
    Only @id and @typeRejected — 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": false explicitly. Omitting the field leaves it primary.
    • @group_primary can be changed without restating an unchanged @group_id.

    See Variant groups.

    DELETE takes identities only:

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

    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 rejected

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

    CauseWhat is rejectedcode
    Missing @id or @typeThe whole batch, 422—
    A partial update with no fieldsThe 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 upsertThe whole batch, 422—
    A missing, null or blank @title on an upsert, after mappingThe objecttitle_missing
    @id is not <type>/<value> with both parts non-emptyThe objectid_invalid
    @id prefix does not match @type — item/123 sent as a productThe objectid_type_mismatch
    An @ field that only Luigi’s Box sets, such as @updated_atThe objectreserved_field_read_only
    An unrecognized @ fieldThe objectreserved_field_unknown
    A field in a namespace Luigi’s Box owns: lbx:, signals:, ml: or lexical:The objectnamespace_forbidden
    A field in any other namespace except private:The objectnamespace_unknown
    A value whose type conflicts with the field’s established typeThe objectfield_type_conflict
    An array whose elements are not all of one typeThe objectarray_mixed_types
    @category or @aliases that is not a list of category/<value> references, or @aliases on an object that is not a categoryThe objectcategory_reference_invalid
    @group_id or @group_primary that is malformed after mapping, or set on an object that is not a productThe objectvariant_group_invalid
    A field name or value containing a null characterThe objectvalue_invalid
    The catalog’s mapping cannot be applied to the objectThe objectmapping_failed
    An object larger than about 1 MB after mappingThe objectobject_invalid
    Any other malformed objectThe objectobject_invalid
    An internal failure; nothing is wrong with the objectThe objectinternal_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.

    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.