Skip to main content
Version: 4.0 preview

The runtime API — configuring a product without a browser

Version
4.0 preview
Updated
View markdown

For anyone writing a client that has to configure a CBX product and put it in a cart: an AI assistant, a headless storefront, a mobile app, an integration test. Everything here is reachable with an HTTP client and a cookie jar. Nothing here needs HTML parsing.

The authoring side — creating products, pages, questions, answers — is a different surface with different rules; see com_configbox_kenedo_controller.md and the MCP server docs for that. The response envelope both surfaces share is com_configbox_api_contract.md, and §7 of it is the normative version of this page.


The shape of a configuration

Product ──> Pages ──> Questions ──> Answers

A customer's work-in-progress lives in a cart position. Its id is the handle for everything: it identifies the configuration while it is being built, and the cart line once it is finished. Get one from startConfiguration and keep it.

finished is the flag that separates the two. An unfinished position is a configuration in progress and is not in the cart; finishing it puts it there; reopening it takes it back out.


Calling convention

POST /cb-api/<controller>/<task>?<parameters>

or, spelled out without SEF routing:

POST /index.php?option=com_configbox&output_mode=view_only
&controller=<controller>&task=<task>&<parameters>

On the index.php form, output_mode=view_only is required — without it the host wraps the JSON in a full page. The /cb-api/ route sets it for you.

One parameter alias pair. A position id is read as cart_position_id first, cartPositionId as the fallback — both names work wherever a position id is taken, stated here once so the per-task listings do not repeat it.

Session. CBX's session is cookie-keyed. Keep the cookies and you keep your cart; drop them and you are a new customer with an empty one. There is no API key: a client that has to act as a known customer logs in through user/loginUser first.

Language. Pass languageTag=de-DE. Not language — on the site application Joomla's language filter eats a request variable by that name before the component sees it, and you would silently get the store's default. An inactive tag is a 400 UNKNOWN_LANGUAGE, never a silent fallback.

Responses. 2xx carries {"data": {...}}. 4xx/5xx carries RFC 9457 problem details with a stable code. Branch on the HTTP status and on code; never on detail, which is translated prose.


The flow

1. Start — configuratorpage/startConfiguration

POST …&controller=configuratorpage&task=startConfiguration&prod_id=25
→ 201 {"data": {"cartPositionId": 757, "productId": 25, "cartId": 749,
"firstPageId": 3, "product": {...}, "pages": [...],
"openQuestions": [], "complete": true}}

Creates the guest customer and the cart when you have neither, so this works on an empty cookie jar.

It resumes rather than duplicates. Called twice for the same product it hands back the same unfinished position — which is what a client that lost its place needs. It starts a fresh one when the current position is for a different product or has already been finished.

2. Learn what there is to answer — getStructure

GET …&task=getStructure&prod_id=25
→ 200 {"data": {"product": {…}, "pages": [{"id": 3, "title": "…", "questions": [...]}]}}

Nothing in it depends on what has been chosen, so fetch it once and keep it. Each question carries its type, whether it is required, its constraints, and its answers where it has them.

getQuestions is the same thing with live state folded in (applies, selection, outputValue) and needs a cartPositionId.

3. Choose — setSelection

POST …&task=setSelection&cartPositionId=757&questionId=11&selection=22
→ 200 {"data": {"accepted": true,
"question": {"id": 11, "selection": "22", "outputValue": "Option A"},
"previous": {"selection": null, "outputValue": ""},
"autoChanges": {"applied": [{"questionId": 12, "selection": "24", …}],
"cleared": []},
"openQuestions": [], "complete": true, "price": {...}}}

For a question with answers, selection is an answer id. For free-value types it is the value. Send an empty selection to clear an answer.

Read autoChanges. Choosing one thing can auto-select or clear another — that is what the store's rules are for. A client that assumes only the question it named changed will report a configuration the store does not have.

Confirmation. When the change would undo selections the customer already made, you get a 200 with accepted:false, needsConfirmation:true, confirmationText and affects[] — and nothing has changed. Show it, then repeat the call with confirmed=1. This is a 200 because the request was fine and the server did the right thing; it is an answer, not a failure.

Refusals. 422 UNKNOWN_ANSWER when the id is not one of that question's answers, 422 QUESTION_NOT_IN_PRODUCT when the question is not part of the product you are configuring, 422 ANSWER_NOT_SELECTABLE when the answer exists but is currently disabled by a rule or flagged unavailable (explainQuestion says which selections would unlock it), 422 VALUE_OUT_OF_BOUNDS when a free value is outside the question's own advertised constraints, and 422 SELECTION_REJECTED when the engine itself rejects the value (question does not currently apply, bad upload).

3b. Find your way — explainQuestion, previewSelection, getDependencies

Steps 1–5 drive a configuration you already know how to reach. These three are for finding one in a product whose rules you have never seen.

Why is this unavailable, and what would fix it?

GET …&task=explainQuestion&cartPositionId=757&questionId=12
→ 200 {"data": {"applies": false,
"rule": {"hasRule": true, "satisfied": false, "negated": false,
"conditions": [{"kind": "condition", "satisfied": false,
"question": {"id": 11, "title": "Colour"},
"field": "selectedAnswer.id",
"operator": "==", "operatorText": "is",
"value": "22", "valueLabel": "Option A",
"currentValue": null, "currentValueLabel": ""}]},
"answers": [{"id": 24, "applies": true, "selectable": false, "rule": {…}}],
"blockedBy": [{"questionId": 11, "title": "Colour",
"needs": {"operator": "==", "operatorText": "is",
"value": "22", "valueLabel": "Option A"},
"currently": ""}]}}

Read blockedBy and you are done: it says which question to change and to what. conditions is there when you want the full logic — combinators (and/or) and nested group items appear in order, so the rule can be rendered or reasoned about in full.

Answers are explained too, with their own rules. A question can apply while the answer you want does not.

What would this choice do?

POST …&task=previewSelection&cartPositionId=757&questionId=11&selection=22
→ 200 {"data": {"committed": false, "wouldBeAccepted": true,
"effects": {"appear": [{"questionId": 12, "title": "Rim design"}],
"disappear": [],
"wouldClear": [], "wouldReplace": []},
"openQuestions": {"before": [...], "after": [...]},
"price": {"before": {...}, "after": {...}}}}

Nothing is committed — that is the whole contract, and committed:false is in every response. Use it to search: probe several answers, compare the after prices and the effects, then commit the one you want with setSelection.

wouldClear and wouldReplace are the important ones before a destructive change: rules drop other selections as a side effect, and changing your mind afterwards does not bring them back.

A value the store would refuse comes back as a 200 with wouldBeAccepted:false and a rejection — "that would not be accepted" is the correct answer to "what would happen".

What do I have to decide first?

GET …&task=getDependencies&prod_id=25
→ 200 {"data": {"questions": [{"questionId": 12, "title": "Rim design",
"dependsOn": [{"questionId": 11, "title": "Colour"}],
"affects": [], "hasRule": true}],
"roots": [{"questionId": 11, …}],
"decisionOrder": [{"questionId": 11, …}, {"questionId": 12, …}],
"cycles": []}}

Work through decisionOrder and nothing you decide is invalidated by something you decide later. roots is where to start. cycles holds questions whose rules reference each other — they cannot be ordered, and are reported separately rather than pretending to a sequence.

Static, so prod_id is enough and a client can cache it until the catalog changes.

A recipe for arriving with no knowledge

  1. getDependencies — learn the shape and the order to work in.
  2. getStructure — learn the questions and answers.
  3. startConfiguration — get a cartPositionId.
  4. For each question in decisionOrder: previewSelection the candidates, compare price.after and effects, then setSelection the winner.
  5. Anything unavailable that you wanted: explainQuestionblockedBy → go set that instead.
  6. getOpenQuestions until complete, then finishConfiguration.

4. Know when you are done — getOpenQuestions

GET …&task=getOpenQuestions&cartPositionId=757
→ 200 {"data": {"openQuestions": [{"questionId": 10, "title": "Width",
"pageId": 3, "reason": "REQUIRED_UNANSWERED",
"message": "…"}],
"count": 1, "complete": false}}

The list to work down: published, required, currently applying, and unanswered. It is the same check that gates finishing, so an empty list means step 5 will be accepted.

You cannot compute this from the structure alone — a rule can make a required question not apply, in which case it is not open.

getConfiguration gives the same list plus the current selections[], price{}, quantity and finished. That is the call for orienting yourself at the start of a conversation.

5. Finish — finishConfiguration

POST …&task=finishConfiguration&cartPositionId=757
→ 200 {"data": {"finished": true, "cart": {...}, "cartUrl": "/en/cart.html"}}

409 CONFIGURATION_INCOMPLETE when something is still open — with the open questions in errors[] as meta.questionId / meta.pageId, so you can go straight to the first one. 409 ALREADY_FINISHED when it is already in the cart.

cartUrl is data. The store configures where customers land after add-to-cart and you cannot derive it; whether to follow it is your decision.


The cart

TaskDoesAnswers
cart/getCartreads it{cartId, positions[], itemCount, totals{}}
cart/setCartPositionQuantityquantity=<n>the line and the cart's new totals
cart/copyPositionduplicates a line201, a new unfinished position
cart/editPositionreopens a linethe configuration, ready to carry on
cart/removePositiondeletes a linethe cart as it now stands

Position tasks take cart_position_id (or its cartPositionId alias — see the calling convention).

getCart takes an optional cart_id (alias cartId); without it you get the session's cart, and no cart yet is a 200 with cartId: 0, not an error. Naming another customer's cart is the one 403 CART_NOT_YOURS on this surface — the position tasks answer 404 for a foreign position instead (see below for why).

Each mutation answers with the resulting state, so you never need a follow-up read.

copyPosition gives you an unfinished copy on purpose: copying means "another one like this, but different". For a second identical line, raise the quantity.

An empty cart is a 200 with no positions — "you have nothing in your cart" is an answer.

A line lists only the selections the store marks "show in overview", same as the cart page. For a line's complete configuration, call getConfiguration with its cartPositionId.


Money

{"net": "100.00", "gross": "119.00", "tax": "19.00", "currency": "EUR", "formatted": "€ 119.00"}

The amounts are decimal strings. A JSON number is a double and a double cannot hold 0.1; add up line items in floats and your totals end up a cent off in ways you cannot explain. Parse them with a decimal type.

formatted is display sugar in the store's locale — never parse it. It changes with languageTag (€ 119.00 vs € 119,00); the amounts do not.

Whether the customer is shown net or gross depends on the store's B2B/B2C mode. formatted already follows that, so you can echo it without knowing the mode.

The cart's totals.items is the sum of the lines. There is deliberately no grand total: delivery and payment are only priced once chosen in checkout, and a "total" that silently excludes shipping is worse than none.


Errors

StatusCodesMeans
400NO_PRODUCT, NO_CART_POSITION, NO_QUESTION, MISSING_PARAMETER, INVALID_QUANTITY, UNKNOWN_LANGUAGE, PRODUCT_POSITION_MISMATCHthe request is unusable — fix the parameters
404PRODUCT_NOT_FOUND, CART_POSITION_NOT_FOUND, QUESTION_NOT_FOUND, CART_NOT_FOUNDno such thing
409CONFIGURATION_INCOMPLETE, ALREADY_FINISHED, CART_NOT_EDITABLEthe state forbids it; retrying unchanged will fail again
422UNKNOWN_ANSWER, QUESTION_NOT_IN_PRODUCT, ANSWER_NOT_SELECTABLE, VALUE_OUT_OF_BOUNDS, SELECTION_REJECTEDwell-formed, but the value is not acceptable
500EXPLAIN_FAILED, PREVIEW_FAILED, DEPENDENCIES_FAILEDa discovery read broke; the detail is logged
403CART_NOT_YOURSgetCart with an explicit cart_id naming another customer's cart
500START_FAILED, SELECTION_FAILED, CART_READ_FAILED, …we broke; the detail is logged

SELECTION_REJECTED is narrower than it looks: it covers only what the engine rejects (question does not currently apply, bad upload). A wrong answer id, a rule-disabled answer and an out-of-bounds value each carry their own code above, refused before the engine sees them.

A cart position that is not yours is a 404, not a 403. "It exists but is not yours" would let a stranger count the store's carts.


Not covered yet

Checkout. cart/checkoutCart and the checkout steps still render pages, so this flow stops at the cart. Addresses, delivery, payment methods and the PSP bridge all come with it.

The storefront's link tasks. cart/addProductToCart, removeCartPosition, editCartPosition and copyCartPosition are <a href>s in the templates and still redirect. Use the tasks above instead; they do the same work through the same models — and, since 3.8.11, behind the same two guards: the position must be the caller's, and the cart's order status must still allow editing.


Where the code is

Projectionshelpers/configuratorapi.phpConfigboxConfiguratorApiHelper
Configurator taskscontrollers/configuratorpage.php
Discovery testtests/specs/api/configurator-discovery.spec.ts (outer repo)
Cart taskscontrollers/cart.php
Machine-readable cataloghelpers/runtimeapicatalog.php — the hand-written twin of this page, rendered into the OpenAPI document. A task added or changed moves it in the same commit
The envelopeclasses/ConfigboxApiResponse.php
End-to-end testtests/specs/api/headless-configurator.journey.spec.ts (outer repo)

Every projection is a static method on the helper and reads nothing from the request, so an in-process caller — the MCP server, the CLI — gets the same shapes without going through HTTP.

One trap if you extend this

Rules are evaluated against "the current configuration", and the core reaches for it via ConfigboxConfiguration::getInstance() with no argument — which resolves through the cart position model's currently-set id. A question's applies() therefore answers for whatever position happens to be set, not the one you passed in. Call ConfigboxConfiguratorApiHelper::bindPosition($positionId) first. Every public method in the helper already does.