The runtime API — configuring a product without a browser
- Version
- 3.x
- Updated
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
getDependencies— learn the shape and the order to work in.getStructure— learn the questions and answers.startConfiguration— get acartPositionId.- For each question in
decisionOrder:previewSelectionthe candidates, compareprice.afterandeffects, thensetSelectionthe winner. - Anything unavailable that you wanted:
explainQuestion→blockedBy→ go set that instead. getOpenQuestionsuntilcomplete, thenfinishConfiguration.
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
| Task | Does | Answers |
|---|---|---|
cart/getCart | reads it | {cartId, positions[], itemCount, totals{}} |
cart/setCartPositionQuantity | quantity=<n> | the line and the cart's new totals |
cart/copyPosition | duplicates a line | 201, a new unfinished position |
cart/editPosition | reopens a line | the configuration, ready to carry on |
cart/removePosition | deletes a line | the 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
| Status | Codes | Means |
|---|---|---|
| 400 | NO_PRODUCT, NO_CART_POSITION, NO_QUESTION, MISSING_PARAMETER, INVALID_QUANTITY, UNKNOWN_LANGUAGE, PRODUCT_POSITION_MISMATCH | the request is unusable — fix the parameters |
| 404 | PRODUCT_NOT_FOUND, CART_POSITION_NOT_FOUND, QUESTION_NOT_FOUND, CART_NOT_FOUND | no such thing |
| 409 | CONFIGURATION_INCOMPLETE, ALREADY_FINISHED, CART_NOT_EDITABLE | the state forbids it; retrying unchanged will fail again |
| 422 | UNKNOWN_ANSWER, QUESTION_NOT_IN_PRODUCT, ANSWER_NOT_SELECTABLE, VALUE_OUT_OF_BOUNDS, SELECTION_REJECTED | well-formed, but the value is not acceptable |
| 500 | EXPLAIN_FAILED, PREVIEW_FAILED, DEPENDENCIES_FAILED | a discovery read broke; the detail is logged |
| 403 | CART_NOT_YOURS | getCart with an explicit cart_id naming another customer's cart |
| 500 | START_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
| Projections | helpers/configuratorapi.php — ConfigboxConfiguratorApiHelper |
| Configurator tasks | controllers/configuratorpage.php |
| Discovery test | tests/specs/api/configurator-discovery.spec.ts (outer repo) |
| Cart tasks | controllers/cart.php |
| Machine-readable catalog | helpers/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 envelope | classes/ConfigboxApiResponse.php |
| End-to-end test | tests/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.