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.
| Topic | This project | Xentral (v3) | weclapp (v2) | Business Central (v2.0) | ERPNext / Frappe | Odoo 19 (JSON-2) | Katana (v1) | commercetools | Stripe (v1) |
|---|---|---|---|---|---|---|---|---|---|
| API style | REST, resources | REST, resources | REST, resources | OData (REST) | REST plus RPC methods | RPC on models | REST, resources | REST, resources | REST, 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 paths | yes, kebab-case | not checked | no, singular camelCase | yes, camelCase | no, DocType name | no, model name | yes, snake_case | yes, kebab-case | yes, snake_case |
| Field names | camelCase | camelCase | camelCase | camelCase | snake_case | snake_case | snake_case | camelCase | snake_case |
| Identifier | UUIDv7 | numeric string | numeric string | GUID | name (string) | integer | integer | UUID plus own key | prefixed id (cus_...) |
| Business number separate | yes (number) | yes (documentNumber) | yes (orderNumber) | yes (number) | name often is the number | yes (name) | yes | orderNumber as key | n/a |
| List response | object: items, page, links | envelope: data, meta, extra, links | result list | OData value list | data list | raw JSON result | data array | results, limit, offset, count, total | object: data, has_more, url |
| Paging | page and size, cursor mode | page and perPage, cursor mode | page and pageSize | $top and $skip, next link | limit start and length | limit and offset | not checked | limit and offset (max offset 10,000) | cursor: starting_after, ending_before |
| Total count | only on request (count=true) | only in table mode | separate /count endpoint | $count | separate count endpoint (v2) | search_count method | not checked | total, can be switched off (withTotal=false) | none |
| Filter syntax | filter[field][op]=value, paths into lines | filter[n][key|op|value] | field-op=value plus filter expressions | $filter (OData) | JSON filter arrays | domain triples | not checked | where predicates | per endpoint parameters |
| Sorting | sort=-createdAt | sort=-createdAt | sort and orderBy | $orderby | order_by | order | not checked | sort=field asc | fixed, newest first |
| Embedding related data | expand up to 3 levels, views | include=lineItems.product | includeReferencedEntities (side loaded) | $expand | child tables inline | read related fields | not checked | expand up to 3 levels | expand[] |
| Field selection | fields=... | not checked | properties=... | $select | fields=[...] | fields | not checked | not checked | no |
| Concurrency | If-Match with version | not checked | version field | ETag and If-Match | modified timestamp | none | not checked | version in body | none |
| State changes | POST .../actions/{action} | not checked | /{entity}/id/{id}/{method} | bound actions (shipAndInvoice) | submit and cancel methods | model methods | not checked | update actions | action endpoints (/confirm) |
| Preview without saving | preview=true | not checked | dryRun | no | no | no | not checked | no | no |
| Errors | Problem Details (RFC 9457) | not checked | not checked | OData error | own format | HTTP status, own body | not checked | own format with error list | error object with type and code |
| Versioning | major in path, additive only | per endpoint in path | v1 and v2 in parallel | v2.0 in path | v1 and v2 in path | new API, old ones removed by Odoo 22 | v1 in path | stable, additive | date based version header |
| Machine readable spec | OpenAPI 3.1, AsyncAPI 3.0, Arazzo 1.0 | OpenAPI 3.0, llms.txt | OpenAPI | OData metadata | none official | none | OpenAPI 3.0, llms.txt | own reference, SDKs | OpenAPI |
| Line item types | product, text, title, subtotal, shipping, surcharge | product, heading, group_total, image, page_break | article, free text, shipping cost items | item, comment, account, charge | child table rows | product, section, note | not checked | line items, custom line items | n/a |
| Price model | price lists per channel and currency, quantity breaks, validity, group discounts | per customer or customer group, validity, currency | per sales channel, quantity scale, validity | price lists and discounts | price lists | pricelists with rules | not checked | standalone prices: channel, customer group, currency, tiers, validity | n/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).
Sources
- Xentral: API introduction
- Xentral: V3 filtering, ordering, pagination
- Xentral: versioning
- Xentral: sales prices
- Xentral: list sales orders V3
- weclapp: API changelog
- weclapp: data model (Peliqan)
- Business Central: salesOrder resource
- Frappe: REST API
- Odoo 19: JSON-2 API (Cybrosys overview)
- Katana: getting started
- commercetools: general concepts
- commercetools: standalone prices
- Stripe: pagination