The entity API
- Version
- 4.0 preview
- Updated
CRUD over the CBX catalog, over HTTP, authenticated with bearer tokens, described by OpenAPI 3.1 and the JSON Schemas the component already ships.
All paths below are relative to the component root (components/com_configbox/).
1. One registry, one pipeline, five surfaces
Read this section and you can predict what any surface does.
Two things are shared, and both live in helpers/entityapi.php (ConfigboxEntityApiHelper):
- The registry — which entities exist, and which model each one is. Every model that returns a
name from
getEntityName()is in it.ConfigboxTypeGeneratorwalks the same answer when it writesgenerated/records/read/*.phpandgenerated/schemas/**, so the entity list, the shipped schemas and the record stubs cannot disagree. - The write pipeline —
runStorePipeline():prepareForStorage→isInsert→validateData→store→ purge. One place, one order, including theisInserttiming that decides whether a create reports itself as a create.
Five surfaces sit on those two, and each adds only what is genuinely its own:
| Surface | Where | What it adds, and nothing more |
|---|---|---|
| REST / HTTP | controllers/apiv1.php | bearer auth, status codes, RFC 9457 bodies |
| MCP / stdio | helpers/mcp.php | which entities are writable, --read-only, prose errors |
| Admin UI | KenedoController::store() / delete() | core.manage, reads the form post, renders |
| CLI | configbox:run-task → the same controller | argument parsing, exit codes |
| in-process | customizations, migrations | nothing — they call the helper directly |
The rule that keeps it one system: a surface may translate (a status, a message, an argument) and may refuse (auth, a policy). It may not re-implement the registry or the pipeline. If a surface needs behaviour they lack, the behaviour belongs in them, once.
What legitimately differs, and what must not
The only honest difference between surfaces is how a $data object is obtained: a form post has
a request to read, an API call has a JSON body, an MCP tool has an arguments map. That difference
sits above runStorePipeline(). Everything below it is identical by construction, not by
agreement — which is the point, because agreement drifts and construction cannot.
So: a fix to storage behaviour goes in ConfigboxEntityApiHelper and every surface gets it. If
you find yourself fixing the same thing twice, one of the two places is wrong.
The hooks, and which one actually runs
There are two afterStore hooks and it is worth knowing which is which:
KenedoModel::afterStore($id, $wasInsert)— the one that does work.store()calls it inside the pipeline, so every surface gets it. The calculations model uses it to wipe the other calc types; that behaviour is therefore identical over HTTP, MCP and the admin form.KenedoController::afterStore($success)— empty in the base class and overridden by nothing in the component. A controller-only hook, and not where per-entity logic belongs.
Why authorization is not in the shared layer
Because the stdio MCP server deliberately has none — its trust boundary is shell access to the
site, the same as every configbox:* command. A check inside the shared class would break that
caller while adding nothing to the others, each of which authenticates before it ever gets there:
the REST controller by bearer token, the admin controller by core.manage. Authorization belongs
to the surface.
Nothing in the shared core is MCP-shaped
The code was extracted from the MCP helper — that is simply where a generic pipeline had been
written first — and its dependencies were renamed to match. The rule authoring vocabulary is
ConfigboxRuleAuthoringHelper (helpers/ruleauthoring.php); ConfigboxMcpRulesHelper survives only
as a deprecated alias. A shared core calling into something MCP-named is a dependency pointing the
wrong way, and it makes one system look like two.
The two forms of every method
Each public CRUD method comes in a name-taking and a model-taking form:
getMany($entity, …) resolves the name against the registry, then calls ↓
getManyForModel($model, $entity, …) the actual work
That is a resolver over a core, not two implementations. The name-taking form serves callers that
speak entity names (REST, MCP). The model-taking form serves callers that already hold a model and
may have no registry entry at all — KenedoController works for every model in the component,
including the many that never opted into type generation.
2. The endpoints
GET /cb-api/v1/ what this store exposes
GET /cb-api/v1/openapi.json the OpenAPI 3.1 document
GET /cb-api/v1/schemas/read/product.json the committed read schema
GET /cb-api/v1/schemas/write/product.json the committed write schema
GET /cb-api/v1/{entity} a page of records
POST /cb-api/v1/{entity} create -> 201
GET /cb-api/v1/{entity}/{id} one record
PATCH /cb-api/v1/{entity}/{id} change some fields -> 200
DELETE /cb-api/v1/{entity}/{id} delete -> 200
{entity} is any name in GET /cb-api/v1/ — the same registry configbox:generate-types walks, so
the entity list and the shipped schemas cannot disagree.
Three schemas per entity, and the distinction matters when reading the OpenAPI document:
| Schema | Describes |
|---|---|
<Entity>Read | one full record, as GET /{entity}/{id} returns it |
<Entity>Write | what a POST or PATCH may send — appears under requestBody, not parameters |
<Entity>ListRow | one row of GET /{entity} — the listing columns only, not the full record |
A collection read deliberately returns less than a single read: a full record carries every joined and
derived column, which is a lot of bytes for a caller deciding what to read next. ListRow is derived
from the same getPropertiesForListing() the runtime uses, so the two cannot disagree — and
specs/api/api-description.spec.ts asserts every ListRow against a real response.
PUT is deliberately absent. It means "replace the resource with this representation", and this
API cannot honour that: the storage pipeline fills unsupplied fields from the stored record, so a PUT
would behave exactly like a PATCH while promising something stronger. It answers 405 and says so.
Not every entity supports every operation
An entity's model declares what makes sense for it — KenedoModel::getApiOperations(), the full
list/read/create/update/delete set by default. The singletons are why the declaration exists:
settings and store are one-row entities seeded at install, so they narrow to
list, read, update — a second settings row or a deleted store record is not a permission problem
but a request that cannot mean anything. The shared layer refuses a withheld operation with 422
naming the supported set (HTTP and MCP identically), and the OpenAPI document reads the same
declaration, so it never advertises an operation that would only ever refuse.
Sensitive fields never come back
A property declared 'apiSensitive' => true (the licence key, the Anthropic API key, a payment
provider's credentials) is writable but never returned: the shared projection strips it from
every read and the read schema marks it writeOnly: true. Send it, and the response — like every
later read — simply does not carry the field. See
com_configbox_property_definition_settings.md §10.
Request bodies
The Content-Type decides how a write body is read, not whether it happens to parse:
application/json(or no content type with a JSON body) — a JSON object of field name => value.application/x-www-form-urlencoded/multipart/form-data— ordinary form fields. Works on every method, not just POST: PHP fills$_POSTfor POST alone, so the other methods are parsed out of the raw body.
Anything else with a non-JSON body is a 422 that says which two content types work.
X-HTTP-Method-Override is honoured on POST only, for proxies and clients that cannot send PATCH
or DELETE. Honouring it on a GET would let a plain link, an <img> or a browser prefetch delete a
record.
Query parameters on a collection read: limit, offset, languageTag, and filter[name]=value.
limit defaults to 50 with a hard ceiling of 200 — asking for more is clamped, not
refused, so a greedy client gets a page rather than an error. Filters are namespaced under
filter so one can never collide with a reserved parameter, now or when a new one is added.
languageTag, not language: on the site application Joomla's language filter consumes a request
variable called language before the component sees it, so language=de-DE silently yielded the
default language.
Multilingual sites redirect
On a site with the language filter on, /cb-api/v1/… answers 301 to /en/cb-api/v1/…. That is
pre-existing behaviour for every /cb-api/ route, not specific to this API. Clients should follow
redirects, or address the prefixed URL directly — and a client that writes must use the prefixed
URL.
Not for the reason you would expect: Joomla's language filter exempts POST, so a create is never
redirected. PATCH, PUT and DELETE are. A client that follows the 301 may re-issue it as a
GET — the historical behaviour RFC 9110 still permits — and then an update answers 200 with an
unchanged record and nothing reports a failure.
3. What a response looks like
The HTTP status carries the outcome; there is no success field. See §2 of
com_configbox_api_contract.md for the argument.
Success — application/json. A single-record read:
{
"data": { "entity": "product", "id": 25, "record": { "…": "…" } }
}
A collection read:
{
"data": { "entity": "product", "records": [ { "…": "…" } ], "fields": [ { "…": "…" } ] },
"meta": { "total": 97, "offset": 0, "limit": 50, "returned": 50 }
}
records carries ListRow rows (§2); fields is the listing-column metadata — name, label and
type per column — so a client can render the rows as a table without fetching the schema. The
paging counts describe the response, which is why they ride in meta.
Failure — application/problem+json, RFC 9457:
{
"type": "https://www.configbox.at/api/errors/validation-failed",
"title": "Validation failed",
"status": 422,
"code": "VALIDATION_FAILED",
"detail": "Field Title cannot be empty.",
"errors": [ { "code": "VALIDATION_FAILED", "message": "Field Title cannot be empty." } ],
"validationIssues": [ { "field": "title", "code": "INVALID", "message": "Field Title cannot be empty." } ]
}
Branch on code, never on detail. Codes are stable and never translated; detail is prose that
may change with a language string edit.
| Status | code | When |
|---|---|---|
200 | — | read, update, delete |
201 | — | create |
401 | AUTHENTICATION_REQUIRED | no token sent at all — carries WWW-Authenticate |
401 | AUTHENTICATION_FAILED | a token that does not verify: mistyped, unknown, revoked or expired — one code for all of them, deliberately (§5) |
403 | INSUFFICIENT_SCOPE | valid token, wrong scope — the message names the exact scope needed, e.g. This token does not carry the "orders:read" scope. Retrying with the same credentials will never help |
404 | NOT_FOUND | no such entity, record or endpoint |
405 | METHOD_NOT_ALLOWED | wrong method — carries Allow |
422 | VALIDATION_FAILED | the data was refused; validationIssues names the fields |
500 | INTERNAL_ERROR | something broke; the detail is in the store's log under the identifier in the message |
The three typed exceptions map straight onto this: KenedoValidationException → 422,
KenedoNotFoundException → 404, KenedoSystemException → 500. A surface that wants a different
status is translating, which is allowed; a surface that wants different behaviour is not.
4. Records and translations
Records cross the wire flat: one key per translatable field per active language, underscore-separated — the same keys the model hydrates and the admin form inputs post. (An earlier revision nested translations under their field; flat won — see §5 of the API contract.)
{ "id": 1, "sku": "CBX-0001", "title_en_GB": "Workbench", "title_de_DE": "Werkbank" }
Every surface, not just this one. The record shape is settled inside
ConfigboxEntityApiHelper, so HTTP, MCP and the CLI return the same shape from a single-record
read and from a list — one field never has two shapes decided by which call you made.
Language keys are underscored (title_en_GB, never a dash), matching the record keys and the
admin form inputs — one derivation (KenedoLanguageHelper::getTagKey()) produces all of them, so
none can drift.
A single read carries the plain key and every language. title holds the text for the language
the read asked for (?language=de-DE, else the store default); title_en_GB and title_de_DE carry
all of them. A collection read is projected down to the listing columns, so it carries the plain
title only — ask for the record when you want every language.
Writes take the flat key: {"title_en_GB": "…"}. The nested {"title": {"en_GB": "…"}} form is
refused with 422, naming the field and the key to use instead — it was accepted once, and letting
it pass silently is how the shape kept coming back.
A create must supply every active language for a required translatable field. That is the model's
own validation, not an API rule: sending only en_GB on a two-language store fails with "Field Title
cannot be empty". A PATCH has no such requirement — unsent languages keep their stored text.
Unknown fields are refused, not ignored. A silently dropped field is the worst available outcome:
the caller is told 200, believes it set something, and the store disagrees.
Platform-dependent fields
ConfigBox runs on Joomla, WordPress, Magento 2 and standalone from one codebase and one set
of migrations. A few dozen fields only mean something on some of those hosts — a product's
baseprice is the clearest case, because on Magento the Magento product owns the price.
Such a field is present on every platform: same column, same schema, readable and writable through this API wherever you are. What varies is only whether the host has the concept:
| on a platform where it applies | elsewhere | |
|---|---|---|
| stored / readable / writable | yes | yes |
| shown in the admin form | yes | no |
can be required | yes | no — platforms overrides required |
| read by the application | yes | no — nothing consumes it |
So writing baseprice on Magento succeeds and the value round-trips; it simply does not price
anything. That is deliberate: one shape everywhere means a client, a generated type and a schema
never disagree about which fields exist, and a value written on the wrong host is inert rather than
rejected.
One exception to "identical everywhere": what a NEW record starts with. A few fields start a
freshly created record at a different value on a host where they do not apply — a product's
page_nav_show_buttons declares 2 ("use the global setting") but starts at 0 on Magento, which
owns page navigation itself. Only the starting value differs; the column, its DB-level default and
every existing row are identical everywhere. Such a field carries x-configbox-platform-defaults
beside its default. It only matters if you omit the field — send a value and that value is
what you get, on every host.
How to tell. Every platform-dependent field carries x-configbox-platforms in the generated
schemas — read and write both — listing the hosts it applies to, and says the same thing in its
description so a reader who ignores extension keys is still told. cbx_describe_entity returns
both.
"baseprice": {
"type": "number",
"description": "Base price — … Platform-dependent: this field only applies on joomla, wordpress, standalone. It is stored and writable on every platform, but elsewhere it is hidden in the admin form, never required, and nothing reads it.",
"x-configbox-platforms": ["joomla", "wordpress", "standalone"]
},
"page_nav_show_buttons": {
"default": "2",
"x-configbox-platforms": ["joomla", "wordpress", "standalone"],
"x-configbox-platform-defaults": {"magento2": 0}
}
A field that is genuinely not writable through the API is a different thing and is reported
separately — see unsupportedFields in cbx_describe_entity (an image needs a multipart upload;
child entries are stored through their own entity). Platform-dependence never puts a field there.
5. Authentication
Bearer tokens, RFC 6750. Every endpoint needs one, including discovery and the schemas — they
describe the store's field structure, languages and entity list, which is business configuration, and
every legitimate client already holds a token. What a token may do is its scopes, each
<area>:<level> — the reference for the seven areas, the rules, the presets and where tokens are
minted is com_configbox_api_tokens_and_scopes.md; this section covers what the REST surface does
with them.
Authorization: Bearer cbx_7_YvH_W6rwY11RumAsUWlq8Hd7tCpuEt1ARPIsFlXZyBs
Mint, list and revoke with configbox:token:* (see §1.10 of com_configbox_cli_commands.md) or
through the API Tokens admin screen (controller=adminapitokens, since 2026-08) — same helper
underneath, same show-once semantics; the screen's store task returns the cleartext in its JSON
payload so a headless admin client can mint too.
The format is cbx_<id>_<secret> and each part earns its place: the id makes verification an
indexed primary-key read instead of a table scan; the cbx_ prefix is what lets a secret scanner
recognise a leaked token, where a bare random string looks like nothing.
Only the SHA-256 is stored. The token is displayed once, at mint time, and nothing can show it
again. SHA-256 rather than a slow KDF is deliberate: these are 32 CSPRNG bytes with no structure to
guess, so there is no dictionary and no offline attack a salt would frustrate — while a bcrypt round
would run on every API request. Comparison is hash_equals(), so the check does not leak how much
of a guess was right.
A scope is <area>:<level>. Seven areas — catalog, store, customers, orders,
conversations, code, system — each entity belongs to exactly one
(ConfigboxApiTokenHelper::getEntityAreas(), or a customization model's own getApiArea()
override), and each request needs the entity's area at the request's level: GET needs <area>:read,
POST/PATCH/DELETE need <area>:write. Within an area write implies read — a token minted
catalog:write can also GET the catalog — but nothing carries across areas: an orders:write token
cannot touch product, and a catalog:write token cannot touch sales-order. Every token also reads
store regardless of its grant (settings, currencies, tax classes, geography — the vocabulary every
other area is expressed in), which is safe only because credential-bearing fields are apiSensitive
and never returned (§2 above, "Sensitive fields never come back"). Grant the
narrowest area:level that works; configbox:token:mint --preset bundles the common combinations.
Every authentication failure answers the same body, whether the token was malformed, unknown, wrong, revoked or expired. Distinguishing them tells a prober which half of a guess was right.
Apache note. The Authorization header does not reach PHP unless CGIPassAuth is on or a rewrite
copies it — and where a rewrite does it, the value arrives as REDIRECT_HTTP_AUTHORIZATION. The
controller reads both names and falls back to apache_request_headers(), so a stock Apache does not
silently refuse every token.
Operational notes
- There is no rate limiting. Stated explicitly rather than left to be discovered: nothing at this layer throttles a caller. If a store needs one, it belongs in front — the web server or a proxy — not in the component.
- Tokens can expire.
mint()takes an optional expiry (configbox:token:mint --expires), andhasExpired()is checked on every verification — an expired token fails like a revoked one, with the same body. - Last use is tracked. Every authenticated request stamps the token row (
touch()), soconfigbox:token:listcan show which tokens are alive and which have been dead weight for months. - No CORS headers are set. A browser-based cross-origin client cannot call this API — the
preflight fails and the response carries no
Access-Control-Allow-*. That is the stance, not a gap: the audience is server-side callers, and a bearer token has no business living in a browser.
6. Discovery
GET /cb-api/v1/openapi.json returns an OpenAPI 3.1 document built per request.
3.1 and not 3.0, because 3.1 uses JSON Schema 2020-12 unchanged — which is exactly what
generated/schemas/ contains. The schemas drop in by $ref to the URLs the API itself serves, so
there is one copy of each. Targeting 3.0 would mean converting every schema on the way out into a
dialect that only resembles JSON Schema (nullable: true, no $id), lossily, per request, with
nobody validating the output — and the document would then describe an API subtly different from the
one the schemas describe.
Assembled per request, not committed, because the entity list, the active languages and the base URL are properties of the store, not of the codebase; a committed document would describe whichever store generated it. The schemas it points at are committed, and are served from disk rather than regenerated, so a client validates against the same artifact that shipped.
Both document endpoints answer bare — the ONE deliberate exception to the data envelope. An
OpenAPI document and a JSON Schema each have their own root-level contract (openapi: "3.1",
$schema/$id), and their consumers are other people's tools: an importer pointed at
/openapi.json, a validator dereferencing a schema URL as a $ref. None of them unwrap an
envelope, so a wrapped document is a document these URLs might as well not serve — which was
precisely the state until 2026-07-31. The schemas answer with their own media type,
application/schema+json. Failures on either endpoint are problem+json like everywhere else: a
problem document is not the resource, so the contract applies to it unchanged. The index
(GET /cb-api/v1/) stays enveloped — it is ours, read by clients that speak this API.
Discovery needs a token, not a specific scope. /, /openapi.json and /schemas/... all
accept any valid token — describing the store's shape is not itself reading or writing an area, so
there is no <area>:<level> to require. What differs is what each one shows: /openapi.json and
the schema files describe every entity in the registry, the same for every caller, because they are
one committed artifact per install, not a per-token view. The index is the one that is filtered —
buildIndex() walks the registry and skips any entity whose area the caller's token cannot read, so
GET /cb-api/v1/ never lists a collectionUrl that would answer 403.
7. Worked example
TOKEN=$(php cli/joomla.php configbox:token:mint "importer" --preset=author | tail -1)
B=https://example.com/en/cb-api/v1
curl -sH "Authorization: Bearer $TOKEN" "$B/" # what exists
curl -sH "Authorization: Bearer $TOKEN" "$B/product?limit=5" # a page
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"page_id":1,"title_en_GB":"Colour","title_de_DE":"Farbe","question_type":"textbox"}' \
"$B/question" # 201
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"internal_name":"colour"}' "$B/question/123" # 200, title untouched
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$B/question/123"
8. Coverage, honestly
What has actually been exercised against a real store, rather than assumed:
- Every registered entity (see
GET /cb-api/v1/for the live list) answersGET /{entity}and serves both schemas. - Just under half of them held records on the development store and every one reads back through the projection. The rest (orders, quotes, payments, geography) were empty there, so their single-record read path is untested, not known-good.
- Full create / read / update / delete round trips on six entities of deliberately different
shapes:
question(FK + translatable +applies-when),page(product FK),salutation(plain translatable),tax-class(non-translatable title plus a numeric),currency(code/symbol with a uniqueness constraint) andproduct-list. - JSON and form-encoded bodies, on POST and on PATCH;
X-HTTP-Method-Override, including that it cannot escalate a GET;limitclamping;filter[…]; an inactivelanguageTag. - Every status code above, both required headers, scope enforcement, and revoked and expired tokens.
On the write path beyond the six. dropUnacceptableDefaults() and
nullBaseTableValuesToEmptyStrings() were originally hardened against the MCP whitelist's six
catalog entities, and the honest expectation was that store-configuration entities would break on
property types those never exercised. Measured, they did not: the four non-catalog entities above
went through create, read and delete unchanged. That is encouraging rather than conclusive — it is
six entities of the whole registry — but "expect breakage" would now be overstating it. The untested remainder is
mostly the transactional entities (orders, quotes, payments and their lines), which are also the ones
where a careless write is most expensive.
meta.failedAt (the exception context trail) is reachable but narrow: KenedoModel::store() adds
its context on failure, so a failure inside store is located, while a validation refusal — which
happens in validateData(), before store — is not. That is the common case and it carries
validationIssues instead, which is the more useful thing.
9. See also
com_configbox_api_contract.md— the response contract every CBX endpoint targetscom_configbox_cli_commands.md§1.9 — the token commandscom_configbox_mcp_server.md— the MCP surface on the same pipelinecom_configbox_type_generation.md— where the schemas and record stubs come fromdocs/platform/joomla/com_configbox_sef_urls.md— how/cb-api/routes