# The runtime API — configuring a product without a browser

> 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 te…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/runtime_api. Last updated 2026-08-10.

---
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: `explainQuestion` → `blockedBy` → 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

| 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

```json
{"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.
