Prior art

Decisions should rest on facts, not taste. So before the first line of code, I compared how eight public APIs solve the same problems: five ERP systems, a commerce platform and the usual benchmark for API design.

Side by side

Based on each vendor's public documentation as of October 2026. "Not checked" means the documentation I could read did not answer the question, not that the feature is missing. Scroll sideways on small screens.

TopicThis projectXentral (v3)weclapp (v2)Business Central (v2.0)ERPNext / FrappeOdoo 19 (JSON-2)Katana (v1)commercetoolsStripe (v1)
API styleREST, resourcesREST, resourcesREST, resourcesOData (REST)REST plus RPC methodsRPC on modelsREST, resourcesREST, resourcesREST, resources
Resource path/api/v1/sales-orders/{uuid}/api/v3/... per endpoint/salesOrder/id/{id}/companies({id})/salesOrders({id})/api/resource/{DocType}/{name}POST /json/2/{model}/{method}/v1/sales_orders/{project}/standalone-prices/{id}/v1/customers/{id}
Plural pathsyes, kebab-casenot checkedno, singular camelCaseyes, camelCaseno, DocType nameno, model nameyes, snake_caseyes, kebab-caseyes, snake_case
Field namescamelCasecamelCasecamelCasecamelCasesnake_casesnake_casesnake_casecamelCasesnake_case
IdentifierUUIDv7numeric stringnumeric stringGUIDname (string)integerintegerUUID plus own keyprefixed id (cus_...)
Business number separateyes (number)yes (documentNumber)yes (orderNumber)yes (number)name often is the numberyes (name)yesorderNumber as keyn/a
List responseobject: items, page, linksenvelope: data, meta, extra, linksresult listOData value listdata listraw JSON resultdata arrayresults, limit, offset, count, totalobject: data, has_more, url
Pagingpage and size, cursor modepage and perPage, cursor modepage and pageSize$top and $skip, next linklimit start and lengthlimit and offsetnot checkedlimit and offset (max offset 10,000)cursor: starting_after, ending_before
Total countonly on request (count=true)only in table modeseparate /count endpoint$countseparate count endpoint (v2)search_count methodnot checkedtotal, can be switched off (withTotal=false)none
Filter syntaxfilter[field][op]=value, paths into linesfilter[n][key|op|value]field-op=value plus filter expressions$filter (OData)JSON filter arraysdomain triplesnot checkedwhere predicatesper endpoint parameters
Sortingsort=-createdAtsort=-createdAtsort and orderBy$orderbyorder_byordernot checkedsort=field ascfixed, newest first
Embedding related dataexpand up to 3 levels, viewsinclude=lineItems.productincludeReferencedEntities (side loaded)$expandchild tables inlineread related fieldsnot checkedexpand up to 3 levelsexpand[]
Field selectionfields=...not checkedproperties=...$selectfields=[...]fieldsnot checkednot checkedno
ConcurrencyIf-Match with versionnot checkedversion fieldETag and If-Matchmodified timestampnonenot checkedversion in bodynone
State changesPOST .../actions/{action}not checked/{entity}/id/{id}/{method}bound actions (shipAndInvoice)submit and cancel methodsmodel methodsnot checkedupdate actionsaction endpoints (/confirm)
Preview without savingpreview=truenot checkeddryRunnonononot checkednono
ErrorsProblem Details (RFC 9457)not checkednot checkedOData errorown formatHTTP status, own bodynot checkedown format with error listerror object with type and code
Versioningmajor in path, additive onlyper endpoint in pathv1 and v2 in parallelv2.0 in pathv1 and v2 in pathnew API, old ones removed by Odoo 22v1 in pathstable, additivedate based version header
Machine readable specOpenAPI 3.1, AsyncAPI 3.0, Arazzo 1.0OpenAPI 3.0, llms.txtOpenAPIOData metadatanone officialnoneOpenAPI 3.0, llms.txtown reference, SDKsOpenAPI
Line item typesproduct, text, title, subtotal, shipping, surchargeproduct, heading, group_total, image, page_breakarticle, free text, shipping cost itemsitem, comment, account, chargechild table rowsproduct, section, notenot checkedline items, custom line itemsn/a
Price modelprice lists per channel and currency, quantity breaks, validity, group discountsper customer or customer group, validity, currencyper sales channel, quantity scale, validityprice lists and discountsprice listspricelists with rulesnot checkedstandalone prices: channel, customer group, currency, tiers, validityn/a

What follows from it

Confirmed

Count only on request

Xentral counts only in table mode, commercetools lets you switch total off, Stripe never counts. Our count=true is in line with that.

Expand up to three levels

commercetools limits reference expansion to three levels, exactly our limit.

Business number separate from id

Every system with documents keeps the document number apart from the technical id.

Actions for state changes

Business Central bound actions, Frappe submit and cancel, weclapp methods: state changes as actions, not field updates.

Price model

commercetools standalone prices and Xentral sales prices carry channel or customer group, currency and validity, like ADR-035 and ADR-036.

llms.txt

Xentral and Katana already publish an llms.txt index for AI tools, which supports requirement H1.

Adopted

Line item types

Xentral types lines as product, heading, group_total, image and page_break. Adopted: line types and a parent line.

Financials on the document

Xentral stores exchange rate and contribution margin on documents. Adopted: an exchange rate snapshot and unit cost per line.

Customs data on lines

Xentral lines carry HS code and country of origin. Adopted: both to product and line snapshot.

Prices include tax flag

Business Central marks whether a document's prices include tax. Adopted: the channel's price basis is snapshotted on every document.

Offset limit

commercetools caps offset at 10,000 and points to cursor iteration beyond. Adopted: page based paging is capped the same way.

Side loading

weclapp can return referenced entities once next to a list instead of inside every row. Adopted: include for lists, as an option next to expand.

Deliberately different

One major version for the whole API

Xentral versions per endpoint. One major version is simpler for partners and agents to reason about.

UUIDs instead of numbers

Most ERPs expose sequential numbers. UUIDv7 avoids leaking volumes and guessing ids (ADR-033).

Problem Details for errors

Each system has its own error format. RFC 9457 is a standard that clients already understand.

If-Match instead of version in body

commercetools sends the version in the body; HTTP already has a header for this, as Business Central uses.

Decided: Decided after this comparison: field naming. Xentral, weclapp, Business Central and commercetools use camelCase in JSON, Stripe, Katana, Frappe and Odoo use snake_case. We switched to camelCase, the majority among the ERP systems compared and native in TypeScript (ADR-048). All proposals above are adopted (ADR-049).