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.

    Authoring API

    View source

    Campaigns live on the platform API and need a token for the https://api.<region>.luigisbox.ai/platform audience.

    POST /platform/v1/campaigns
    GET /platform/v1/campaigns
    GET /platform/v1/campaigns/{campaign_id}
    PATCH /platform/v1/campaigns/{campaign_id}
    DELETE /platform/v1/campaigns/{campaign_id}
    POST /platform/v1/campaigns/validate-targeting
    POST /platform/v1/campaigns/trigger-schema
    GET /platform/v1/campaigns/{campaign_id}/effective-scope

    The first five are the resource; the last three are authoring aids — they resolve targeting and tell you what is authorable, without persisting anything. They are covered under Targeting and Triggers.

    FieldRequiredNotes
    organization_idYesImmutable
    nameYes1–256 characters
    matchYesThe trigger tree
    rulesYes1–50 rules
    statusNodraft, active, paused. Defaults to draft.
    starts_at / ends_atNoUTC instants. Neither may fall before today, read in the offset the value carries.
    timezoneNoInformational; records the zone the schedule was authored in
    catalog_ids, channel_ids, surface_ids, intentsNoTargeting; empty means all
    catalog_tags, channel_tags, surface_tagsNoTargeting by tag
    disclosureNo{ "type": "sponsored", "advertiser": "…", "payer": "…" } when the campaign’s placements are paid for. Both names are optional, 1–128 characters. See Disclosing a paid campaign.

    Each rule:

    FieldRequiredNotes
    nameYes1–256 characters
    record_matchYesProduct selector. Needs at least one condition for a pin or a ban.
    effectYesOne of pin, ban, boost, bury

    The response is the created campaign with its lbm_… ID, each rule’s lbr_… ID, and audit fields. See Overview for a complete request.

    created_by and updated_by identify the person who made the write, when it was made with a user’s token — in the Hub, for example. Treat the value as an opaque string: compare it, don’t parse it. A write made with a server-to-server token records no person, and both fields are null.

    { "type": "pin", "position": 1 }
    { "type": "ban" }
    { "type": "boost", "strength_pct": 0.4, "mode": "hard" }
    { "type": "bury", "strength_pct": 0.6, "mode": "soft", "decay_window": "P7D" }

    position is a positive integer; strength_pct is between 0 and 1 inclusive; decay_window is an ISO 8601 duration, valid only with mode: "soft", and must be positive. mode applies to boost and bury and defaults to hard.

    Set disclosure when the campaign’s placements were paid for. type says how they are disclosed; advertiser names on whose behalf they are shown, and payer who paid for them. Both names are optional, and returned on the hit as written, untranslated:

    {
    "name": "Northwear autumn push",
    "disclosure": {
    "type": "sponsored",
    "advertiser": "Northwear Ltd",
    "payer": "Northwear Group"
    },
    "match": { "operator": "and", "conditions": [] },
    "rules": [ … ]
    }

    A served hit carries the same object under @disclosure, plus recipient_parameters: what decided that the shopper sees the placement on that request. It is read off the trigger fields the campaign’s match tests and its rules’ selectors reference, and you do not set it.

    The campaign references/search/collection/recommender
    trigger.request:querysearch_query——
    a catalog field, such as trigger.brand—collectionanchor
    trigger.request:anchor_id——anchor
    a boost, on the hit it liftedsearch_query, when the search has a querycollectionanchor, when the request has anchors
    none of the abovenonenonenone

    A hit gets the code in its endpoint’s column for every row that applies, and none when none does. A boost only lifts products the request found itself, so the request chose who sees them; a pin places its product whatever the request was. So the boost row does not apply when any campaign’s pin placed the hit, or when only a pin’s own lookup found it. An ad platform’s placement carries provider and tracking instead. See Anatomy of a response.

    The declaration is per campaign, not per rule: every hit the campaign’s rules pinned or boosted carries a @disclosure object. A buried hit does not, and neither does a product that never reaches the results.

    On PATCH the object is replaced as a whole — send it to set it, null to clear it, omit it to leave it alone. An object without a payer takes the stored one away. A campaign that discloses nothing reads back "disclosure": null; a name the campaign does not set is an absent key, exactly as on the hit.

    SituationResult
    advertiser or payer empty, only spaces, or over 128 characters422
    advertiser or payer with a control character, invisible mark or bidirectional override422, exception_details.validation_errors, type disclosure_name_unprintable
    payer without an advertiser422, exception_details.validation_errors, type disclosure_payer_requires_advertiser
    recipient_parameters, provider or tracking in disclosure422
    SituationResult
    A name already used by another live campaign in the organization409, code campaign_name_taken
    Any update to an archived campaign409, code campaign_archived
    An organization_id that does not exist422, code organization_not_found
    channel_ids, surface_ids or their tags without catalog_ids / catalog_tags422, code catalog_scope_required
    intents together with surface_ids / surface_tags422, code intents_conflict
    A catalog or channel from another organization, directly or through a tag422, code catalog_not_in_organization / channel_not_in_organization
    A tag that does not exist in the organization422, code tag_not_found
    A tag of one dimension used in another, such as a channel tag in catalog_tags422, code tag_dimension_mismatch
    A surface ID that exists in none of the targeted catalogs422, code surface_not_in_catalog_scope
    A targeted catalog hosting none of the targeted surface IDs422, code catalog_without_targeted_surface
    A channel whose catalog is not among the targeted catalogs422, code channel_outside_catalog_scope
    An intent no targeted catalog serves422, code catalog_without_targeted_intent
    Deleting a tag that is a live campaign’s only narrowing on its dimension409, code tag_is_last_narrowing
    A pin or ban whose record_match has no conditions422, type record_match_selects_nothing
    Two pins naming one product each, on the same position or the same product422
    decay_window on mode: "hard", or ≤ 0422
    A condition tree in groups nested deeper than 10, or over 100 conditions, counted as stored422, exception_details.filter_problems, code filter_too_complex
    A condition tree many times past those limits, refused before it is parsed422, exception_details.validation_errors, type filter_too_complex
    An in / not_in condition naming more than 100 values422, exception_details.filter_problems
    An @id (or id) value that is not a full <type>/<id> — "", sku-4410 or product/ — in a selector, or such a trigger.request:anchor_id in a trigger422, exception_details.filter_problems
    A trigger_ref naming a field other than the condition’s own, trigger.request:anchor_id aside422, exception_details.filter_problems
    An operator the field does not accept in some targeted catalog422, exception_details.filter_problems
    A trigger.request:* field that is not one of the three422, exception_details.filter_problems
    starts_at not strictly before ends_at422, code starts_at_not_before_ends_at
    starts_at earlier than today422, code starts_at_in_past
    ends_at earlier than today422, code ends_at_in_past
    timezone with neither starts_at nor ends_at422

    A rule saved before one of these checks existed is held to it the next time it is validated: when you send rules again, or widen catalog_ids / catalog_tags, which re-checks the stored rules against the catalogs added.

    Targeting problems come back in exception_details.targeting_problems, each with a stable code, the dimension it concerns and the offending values — so an authoring UI can point at the field that is wrong rather than showing one message:

    {
    "reason": "campaign targeting is invalid: 2 problems",
    "exception_details": {
    "targeting_problems": [
    {
    "code": "intents_conflict",
    "dimension": "intent",
    "values": ["search"],
    "message": "intents cannot be combined with surface_ids or surface_tags"
    },
    {
    "code": "catalog_not_in_organization",
    "dimension": "catalog",
    "values": ["lbc_8w3k2p"],
    "message": "catalogs ['lbc_8w3k2p'] not found in organization"
    }
    ]
    }
    }

    Every violation in the set is reported at once, so one round trip tells you everything that is wrong. A 422 carries this list or filter_problems, never both: the first check to refuse ends the request. The depth and condition caps are checked before the targeting, so a tree past one says nothing about the targeting; any other condition problem means the targeting was accepted. A catalog, channel or tag deleted while the write is in progress is reported the same way, under the code it would get had it been gone when the request arrived.

    The schedule problems and organization_not_found come back in exception_details.validation_errors instead, the shape every field-level rejection on the platform uses: loc is the path to the offending field, type is the code from the table above, and msg is the sentence for a human.

    Condition problems come back the same way, in exception_details.filter_problems. Each one says exactly which condition it is about, so a tree using the same attribute in several rows stays diagnosable:

    {
    "reason": "campaign filter is invalid: 1 problem; first: operator 'gt' requires a numeric field; 'price' has inferred type string",
    "exception_details": {
    "filter_problems": [
    {
    "tree": "record_match",
    "rule": 2,
    "rule_id": "lbr_9k2m4p",
    "condition": 1,
    "field": "price",
    "catalog_ids": ["lbc_8w3k2p", "lbc_p4m9tz"],
    "code": "filter_type_mismatch",
    "message": "operator 'gt' requires a numeric field; 'price' has inferred type string"
    }
    ]
    }
    }

    A tree past a cap is reported the same way, with code filter_too_complex, against the condition that took it over the limit — which is where the tree has to be cut, in whichever syntax you wrote it. A tree many times past the limits is refused before it is parsed, so there is no condition to name: it comes back under validation_errors instead, with the same filter_too_complex as its type. Branch on that one code wherever it appears. Nothing within the limits above reaches that.

    Only conditions count towards the 100, not the groups holding them, so a flat list of 100 conditions fits. In the map syntax each operator is a condition of its own, so {"price": {"gte": 10, "lte": 50}} counts as two — and a node like that, one naming several fields, or one using exists: false, is stored as a group of its own, one level deeper. The count is of the tree as stored, so the exists paired onto a negation counts too — usually one per negation, though negations on one field inside the same and share one. When those checks are what tip the tree over, the rejection names both counts: filter exceeds max 100 conditions (60 sent, 120 after pairing negations with exists). The condition index still counts the tree you sent, so it points at a row you can edit. Depth is measured on the stored tree as well: a negation under an or is wrapped with its exists in an and of their own, one level deeper, and a tree that only goes too deep that way is refused with filter nesting exceeds max depth 10 after pairing negations with exists.

    tree is match for the trigger or record_match for a product selector. rule_id names the condition’s rule outright; use it whenever it is set. It is null for a match problem and for a rule the request is creating, which has no id yet. rule is the same rule by position — in the rules array you sent, or, when a scope change re-checked rules you did not send, in the stored rules ordered by id. condition is the condition’s position among that tree’s conditions, counting leaves in the order they appear in the tree you sent — so it indexes the payload you are holding. For a stored rule a scope change re-checked, it counts the tree as GET returns it.

    code is what to branch on. message explains the same thing for a person and may be reworded at any time, so do not match on it. The two a discovery request returns when it refuses a filter mean the same here, and a save adds four more — it sees the value you wrote, where a query only sees one already stored:

    codeCause
    filter_unknown_fieldThe field is not one the catalog or the trigger vocabulary has
    filter_type_mismatchThe field’s inferred type refuses this operator or value
    filter_unsupported_operatorThe operator is not offered on this field at all
    filter_value_shapeThe value is the wrong shape — a list where a scalar belongs, an empty list, a malformed range, an @id that is not <type>/<id>
    filter_forbidden_fieldThe field sits in a namespace no condition may name
    filter_too_complexThe tree is nested too deep or has too many conditions

    catalog_ids lists the targeted catalogs that refused the condition, sorted. It is empty when the refusal is structural — a malformed value, an operator that exists nowhere — since that holds whatever the catalog. You get one entry per condition and reason, so a row rejected the same way by five catalogs is reported once naming all five, while a row rejected for two different reasons appears twice. Every invalid condition is listed and the response is never cut short.

    The depth and condition caps are the one exception, because they are checked first. When a tree is over one, the 422 lists every tree that is over one and nothing else, since the tree has to be cut before its other conditions are worth judging. Once it fits, the next save reports the rest.

    A 409 from creating or updating a campaign names its cause in exception_details.error_code. As with the other codes, branch on the code and not on reason, which may be reworded at any time:

    {
    "reason": "campaign name already exists",
    "exception_details": {"error_code": "campaign_name_taken"},
    "request_id": "01JQ8H3Z9K2F7M4N6P8R0S2T4V"
    }
    error_codeCauseWhat to do
    campaign_name_takenAnother live campaign in the organization uses the nameChoose another name. Archived campaigns release their names, so it only has to differ from draft, active and paused ones
    campaign_archivedThe campaign is archived and accepts no updateCreate a new campaign

    Unlike the general advice for a 409, re-reading the campaign does not resolve these: a campaign carries no version, so no 409 asks you to reload it and merge your edit, and neither code is worth retrying unchanged — each needs a different request. The 409 you get from editing a tag’s members or deleting it, or deleting a catalog, channel or surface, has a different body: it names the blocking campaign in exception_details.campaign_id and its causes in exception_details.targeting_problems.

    PATCH is partial: a field the body omits is left unchanged.

    Terminal window
    curl -X PATCH 'https://api.eu1.luigisbox.ai/platform/v1/campaigns/lbm_p7q2zx' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{"status": "paused"}'

    One exception: a rules list replaces the whole rule set. To change one rule, send them all. Read the campaign, edit the rules array, send it back. A partial rules list is not a partial update — it is a smaller rule set.

    Echo each rule’s id back. A rule sent with the id from a previous response is updated in place and keeps that ID; a rule sent without one is created fresh, and a rule missing from the list is dropped. The ID is what reporting aggregates a rule’s performance on, so recreating a rule you meant to edit splits its history in two.

    Keep the rules in order. The array’s order is the campaign’s rule order, and between two of its rules competing for the same product the one listed first wins. Rebuilding the list in a different order is how you re-order a campaign — so if you assemble it from a map or a set, you will change which rule wins without meaning to, and nothing in the response will say so.

    A schedule bound is only held to the “not before today” rule when the body actually moves it. Sending a campaign’s current starts_at back unchanged is how a read-modify-write works, so a campaign that started months ago stays editable; only a bound moved onto an earlier day is rejected. A bound sent alone is checked against the stored other one, so moving ends_at to before the stored starts_at is rejected with 422 (starts_at_not_before_ends_at).

    organization_id is immutable. An archived campaign rejects every update with 409, including a status change, and so does a name another live campaign already uses.

    DELETE /platform/v1/campaigns/{campaign_id} sets the status to archived rather than erasing the record, so what ran stays auditable. It responds 204.

    There is no un-archive. Use paused for anything you might want back. Archiving releases the campaign’s name, so a new campaign may reuse it.

    Terminal window
    curl -G 'https://api.eu1.luigisbox.ai/platform/v1/campaigns' \
    --data-urlencode 'organization_id=lbo_r2vn8c' \
    --data-urlencode 'status=active' \
    --data-urlencode 'intent=search' \
    --data-urlencode 'query=spring' \
    --data-urlencode 'sort=starts_at:desc' \
    -H 'Authorization: Bearer <token>'
    FilterMatches
    organization_idOwning organization
    statusLifecycle state
    queryCase-insensitive substring of the name
    effect_typeCampaigns containing a rule with this effect. Repeatable.
    intentIntents the campaign can actually serve right now. Repeatable.

    Filters combine AND across facets and OR within a repeatable one.

    intent matches against the intents of the surfaces currently in the campaign’s scope, not the stored intents array. A catalog-wide campaign matches every intent its catalogs host, and a campaign whose targeted surfaces were deleted matches none — the same answer effective scope gives.

    sort takes <field>:<direction>, with the field one of name, status, rules_count, starts_at or updated_at, and the direction asc or desc.

    The response is the standard paginated envelope plus facet_counts.status, a breakdown by status that ignores the status filter — so a UI can show “12 active, 3 paused” alongside a filtered list.

    Rules per campaign1–50
    Campaign and rule name1–256 characters
    Condition tree depth10
    Conditions per tree100, counted after negations are paired with exists