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.
Campaigns live on the platform API and need a token for the
https://api.<region>.luigisbox.ai/platform audience.
POST /platform/v1/campaignsGET /platform/v1/campaignsGET /platform/v1/campaigns/{campaign_id}PATCH /platform/v1/campaigns/{campaign_id}DELETE /platform/v1/campaigns/{campaign_id}
POST /platform/v1/campaigns/validate-targetingPOST /platform/v1/campaigns/trigger-schemaGET /platform/v1/campaigns/{campaign_id}/effective-scopeThe 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.
Creating a campaign
Section titled “Creating a campaign”| Field | Required | Notes |
|---|---|---|
organization_id | Yes | Immutable |
name | Yes | 1–256 characters |
match | Yes | The trigger tree |
rules | Yes | 1–50 rules |
status | No | draft, active, paused. Defaults to draft. |
starts_at / ends_at | No | UTC instants. Neither may fall before today, read in the offset the value carries. |
timezone | No | Informational; records the zone the schedule was authored in |
catalog_ids, channel_ids, surface_ids, intents | No | Targeting; empty means all |
catalog_tags, channel_tags, surface_tags | No | Targeting by tag |
disclosure | No | { "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:
| Field | Required | Notes |
|---|---|---|
name | Yes | 1–256 characters |
record_match | Yes | Product selector. Needs at least one condition for a pin or a ban. |
effect | Yes | One 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.
Effect shapes
Section titled “Effect shapes”{ "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.
Disclosing a paid campaign
Section titled “Disclosing a paid campaign”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:query | search_query | — | — |
a catalog field, such as trigger.brand | — | collection | anchor |
trigger.request:anchor_id | — | — | anchor |
| a boost, on the hit it lifted | search_query, when the search has a query | collection | anchor, when the request has anchors |
| none of the above | none | none | none |
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.
| Situation | Result |
|---|---|
advertiser or payer empty, only spaces, or over 128 characters | 422 |
advertiser or payer with a control character, invisible mark or bidirectional override | 422, exception_details.validation_errors, type disclosure_name_unprintable |
payer without an advertiser | 422, exception_details.validation_errors, type disclosure_payer_requires_advertiser |
recipient_parameters, provider or tracking in disclosure | 422 |
Validation you will meet
Section titled “Validation you will meet”| Situation | Result |
|---|---|
A name already used by another live campaign in the organization | 409, code campaign_name_taken |
| Any update to an archived campaign | 409, code campaign_archived |
An organization_id that does not exist | 422, code organization_not_found |
channel_ids, surface_ids or their tags without catalog_ids / catalog_tags | 422, code catalog_scope_required |
intents together with surface_ids / surface_tags | 422, code intents_conflict |
| A catalog or channel from another organization, directly or through a tag | 422, code catalog_not_in_organization / channel_not_in_organization |
| A tag that does not exist in the organization | 422, code tag_not_found |
A tag of one dimension used in another, such as a channel tag in catalog_tags | 422, code tag_dimension_mismatch |
| A surface ID that exists in none of the targeted catalogs | 422, code surface_not_in_catalog_scope |
| A targeted catalog hosting none of the targeted surface IDs | 422, code catalog_without_targeted_surface |
| A channel whose catalog is not among the targeted catalogs | 422, code channel_outside_catalog_scope |
| An intent no targeted catalog serves | 422, code catalog_without_targeted_intent |
| Deleting a tag that is a live campaign’s only narrowing on its dimension | 409, code tag_is_last_narrowing |
A pin or ban whose record_match has no conditions | 422, type record_match_selects_nothing |
| Two pins naming one product each, on the same position or the same product | 422 |
decay_window on mode: "hard", or ≤ 0 | 422 |
| A condition tree in groups nested deeper than 10, or over 100 conditions, counted as stored | 422, exception_details.filter_problems, code filter_too_complex |
| A condition tree many times past those limits, refused before it is parsed | 422, exception_details.validation_errors, type filter_too_complex |
An in / not_in condition naming more than 100 values | 422, 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 trigger | 422, exception_details.filter_problems |
A trigger_ref naming a field other than the condition’s own, trigger.request:anchor_id aside | 422, exception_details.filter_problems |
| An operator the field does not accept in some targeted catalog | 422, exception_details.filter_problems |
A trigger.request:* field that is not one of the three | 422, exception_details.filter_problems |
starts_at not strictly before ends_at | 422, code starts_at_not_before_ends_at |
starts_at earlier than today | 422, code starts_at_in_past |
ends_at earlier than today | 422, code ends_at_in_past |
timezone with neither starts_at nor ends_at | 422 |
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:
code | Cause |
|---|---|
filter_unknown_field | The field is not one the catalog or the trigger vocabulary has |
filter_type_mismatch | The field’s inferred type refuses this operator or value |
filter_unsupported_operator | The operator is not offered on this field at all |
filter_value_shape | The 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_field | The field sits in a namespace no condition may name |
filter_too_complex | The 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_code | Cause | What to do |
|---|---|---|
campaign_name_taken | Another live campaign in the organization uses the name | Choose another name. Archived campaigns release their names, so it only has to differ from draft, active and paused ones |
campaign_archived | The campaign is archived and accepts no update | Create 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.
Updating
Section titled “Updating”PATCH is partial: a field the body omits is left unchanged.
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.
Archiving
Section titled “Archiving”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.
Listing
Section titled “Listing”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>'| Filter | Matches |
|---|---|
organization_id | Owning organization |
status | Lifecycle state |
query | Case-insensitive substring of the name |
effect_type | Campaigns containing a rule with this effect. Repeatable. |
intent | Intents 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.
Limits
Section titled “Limits”| Rules per campaign | 1–50 |
| Campaign and rule name | 1–256 characters |
| Condition tree depth | 10 |
| Conditions per tree | 100, counted after negations are paired with exists |
See also
Section titled “See also”- Business rules overview — the concepts these fields express
- Targeting · Triggers · Rules and effects
- API Reference — the generated contract for every endpoint above
Was this page helpful?
Thanks.