Conventions
What holds for every operation of the Stabiso API, so the reference need not repeat it. The last section is the data-vNext API's own conventions.
- Authentication
- Requests and responses
- Idempotency
- ETag and If-Match
- Errors
- Money, numbers and dates
- Approval policy outcomes and four eyes
- Lists, limits and rate limits
- Tenancy and entities
- Audit and correlation
- data-vNext
Authentication
Send an API key with every request except GET /health:
Authorization: Bearer erp_<prefix>_<secret>
or X-API-Key: erp_<prefix>_<secret>. A missing, malformed or revoked key answers 401
(unauthorized). The key decides the organization (every request is confined to it, see
tenancy), the actor type (human or agent) and the scopes
(read, draft, post, approve, admin, audit; admin implies all). A request that lacks the
scope of the operation answers 403 (forbidden). The scope of each operation is in the
reference and in the OpenAPI document as x-required-scope, and
getting started says what each scope allows.
The web UI signs in with a Google account and keeps an HttpOnly session cookie. That is not an
integration path: cookie requests that change state must also send X-ERP-UI: 1, and API clients
should use keys.
Requests and responses
- Bodies are JSON: send
Content-Type: application/json. Uploads (bank statements, e-invoices, bill capture) aremultipart/form-datawith a form fieldfile. - Responses are JSON, except files (QR-bill SVG and PDF, pain.001 and XRechnung XML, VAT return
files, stored originals), which carry their own content type; stored files also send
X-Content-SHA256. - Creating a resource answers
201; deleting one answers204without a body; everything else200. - Paths are
/v1/<plural-noun>, and/v1/entities/{entity}/...for what belongs to one entity ({entity}is its code, for exampleCH01). Actions on a resource arePOSTverbs such as/v1/documents/{id}/submit. /v1changes additively only: new operations, new optional parameters, new response fields. Clients must ignore response fields they do not know. A breaking change would be/v2. There are two recorded exceptions: on 2026-10-02POST /v1/periods/{id}/reopenstarted requiring a body{"reason": "..."}(a control gap otherwise), and on 2026-10-12 data-vNext'sPOST /v1/fixed-assets/depreciation-schedule(and the MCP tooldepreciation_schedule) started returning each ofdata.notesas an object{code, details, text}instead of an English string, so that the notes can be worded in the reader's language (textkeeps the English); see the release notes, which list every change that integrators notice.
Idempotency
Most operations that create something or start a process take an Idempotency-Key header (up to
200 characters). The reference marks them: the row Idempotency-Key: required of an operation, and
x-idempotency: required in the OpenAPI document. Without the key the request is refused with 422
(invalid, field header: "Idempotency-Key"). A few creating operations do not use a key yet (for
example POST /v1/api-keys and POST /v1/users; backlog item B-18): their reference page says
not used, and a retry after an unknown outcome should first look at what exists.
- The key identifies one logical write. Generate a fresh one for each write (a UUID is a good choice) and reuse it only to retry that same request after a timeout or a dropped connection.
- A retry with the same key and the same request returns the stored response, with the same status, and does nothing else. The effect and the stored response are committed in one database transaction, so a crash can never leave one without the other.
- The same key with a different request (another body or path) answers
409(conflict). - Keys are scoped to the organization, shared by all operations, and kept without expiry at present: do not reuse a key for a different purpose.
- State changes that are guarded by the state of the resource take no key and are safe by
design: approving an approval request twice answers
409. PUT(replace settings),PATCHandDELETEare idempotent by definition and take no key.
ETag and If-Match
A resource with a version returns it as an ETag header and honours If-Match, so that two people
editing the same thing do not overwrite each other. Today these are documents ("document-3":
GET, PATCH, POST .../submit, .../void, .../reopen) and the approval policy
("policy-1": GET, PUT, DELETE).
- Read the resource, remember the
ETag, and send it back asIf-Match: "document-3"with the change.If-Matchis optional (without it the change applies to whatever is current, still guarded by the resource's state), accepts a comma-separated list and*. - If the resource changed in the meantime the answer is
412(precondition_failed) with thecurrent_versionin the error. Read it again, merge, retry with the new ETag. - The version is raised by the database on every change.
The approval policy workflow shows a 412 end to end.
Errors
Every error the ERP raises on purpose has the same body, and the HTTP status says the class:
{"error": {"code": "conflict", "message": "period 2026-08 is closed"}}
message is for people; do not parse it. Some errors add fields next to code and message:
problems (a list of reasons for a rejected policy table or a document that failed validation),
header (the missing header), current_version (on 412), violations (e-invoice rules), field,
fields (on a request that does not validate, below).
| Status | error.code |
Meaning |
|---|---|---|
| 401 | unauthorized |
No key, or an invalid or revoked key. |
| 403 | forbidden |
The key lacks the scope; the key belongs to no person where a person is needed; an agent attempted a human-only action. four_eyes (action): you made or requested what you decide, and a second person must do it, whatever your scopes. Browser sessions without X-ERP-UI: 1 get csrf. |
| 403 | policy_denied |
The approval policy denies the action; the error adds action, rule_id and table_id (for example releasing a payment you created). |
| 429 | none | Too many failed authentications from your address; Retry-After says when to try again. |
| 404 | not_found |
The thing does not exist, or belongs to another organization. |
| 409 | conflict |
The request conflicts with the state: a closed period, an idempotency key reused for another request, a document not in the state the action needs, a duplicate. |
| 409 | integrity_violation |
A rule that the database itself enforces refused it (immutability, balance, closed period, a lock). |
| 412 | precondition_failed |
If-Match names an old version. |
| 422 | validation_failed, invalid |
The request is well formed but not acceptable: an unbalanced entry, an unknown account, a missing Idempotency-Key, a rule of the business. |
| 422 | validation_failed with fields |
The body or a parameter does not parse (a missing field, a wrong type, a value out of range). message says each problem in a sentence ("Line 2: the quantity must be above zero"). fields lists each one with pydantic's loc, type and msg, its sentence (message), and its parts to word it in another language: items (name and number from 1), field (the name as sent) and the rule it breaks, with its limit, the count its words agree with and the choices of a choice. A rule a model checks itself has no rule: its message is its own sentence. FastAPI's detail list stays beside the envelope. |
| 502 | refdata_error, agent_failed, mail_failed |
The reference data service, the language model or the mail server failed. Retry later (a message the mail server refused is not recorded as sent). |
| 503 | agents_unavailable, store_unavailable |
The built-in agents are not configured on this server (/v1/agents/*); or the document store could not serve a file (a stored original, an archived output or an attachment): retry later. |
| 500 | database_error |
An unexpected database error. The answer carries a request_id (yours from X-Request-Id, or a new one) to quote to support; it never contains the database's own message. |
The reference lists, for every operation, the statuses it can answer.
Money, numbers and dates
- Money is a decimal string:
"1500.00", never a JSON float. Amounts in answers have at least two decimals, or the currency's own minor units ("1500"for JPY). Rates and quantities are decimal strings without padding ("8.1","10"). Requests accept decimal strings; JSON numbers are parsed as decimals too, but a binary float already lost precision before it was sent, so send strings. - Rounding is half-up to the currency's minor unit, per line, as on an invoice; the figures that follow from it (totals, VAT, functional-currency amounts) are computed by the server.
- Currencies are ISO 4217 codes; an entity keeps its books in its
functional_currency, and foreign-currency amounts carry the exchange rate used (ECB fixings from data-vNext, recorded with their dataset version). - Dates are ISO 8601 calendar dates,
2026-09-21. Timestamps are UTC,2026-09-21T09:30:00Z. Posting dates decide the fiscal period; the period must exist and be open. - Periods in VAT and reports are written
2026-08(month),2026-Q3,2026-H2or2026. - Ids are UUIDs; entities, accounts, parties, assets and documents can also be named by their
business code or number (
CH01,1020,C-DE,FA-001,INV-2026-000001).
Approval policy outcomes and four eyes
Anything that books, and every control action, asks the organization's
approval policy: allow, require_approval or deny.
| What you see | Meaning |
|---|---|
status: posted, policy.decision: allow |
It was done. Posted entries and documents are immutable; corrections are reversals and credit notes. |
status: pending_approval, an approval_id |
It waits for a second person. The reason is in policy.reason (the rule that fired) or says the submitting key has no post scope. Nothing is booked yet. |
403 forbidden |
The policy denies the action for this actor. |
A request for approval is decided with POST /v1/approvals/{id}/approve or .../reject (scope
approve, human). Approving posts what waited. Nobody approves their own request: the check
compares the people behind the keys, so a key counts as the person who requested; agents never
approve. Four eyes compare people, not keys: another key or the browser session of the requester
is still the requester, a built-in agent run counts as the person who ran it, and nobody approves
a journal entry they drafted. A key counts as the person who created it; the organization's first
key (the one erp create-org prints) and every key created with it were issued by the system and
belong to no person: they may request, but never approve, activate a vendor, release a payment
file or finalise a VAT return. The second person is a user who signed in to the web UI (invited with
POST /v1/users) or a key that user created (getting started).
Two more rules of the default policy separate duties: who created a payment does not release it
(policy action release_own_payments) and who prepared or last computed a VAT return does not
finalise it (finalize_own_vat_return); both answer 403 policy_denied and name the remedy, a
second person, or an admin allowing the action in the organization's policy.
The default policy lets people with the post scope post directly, sends every manual
entry of an agent and every agent posting above 5,000 to a person, and keeps these actions for
humans: approving, closing and reopening periods, managing keys, releasing payment files,
finalising VAT returns, activating customers and vendors and changing their bank details.
Whatever the policy says, agents are always denied everything but posting.
Lists, limits and rate limits
- A list is a JSON array, newest or most relevant first, narrowed by query parameters. Lists that can
grow take
limit(default 100, some 200; at most 1000). There is no cursor pagination yet: narrow with filters (start,end,status,q) and when a list needs it, it will gain acursorparameter without breaking/v1. - The API enforces no rate limit of its own. The platform (Cloud Run) limits concurrency and
request size. Be a good client: retry only idempotent requests, reuse the
Idempotency-Key, and back off on502and503.
Tenancy and entities
- The organization is always taken from the key. No path or body carries an organization id, and
a request can never name another organization: an id from another tenant answers
404, and the database enforces it with row-level security, not only the application. - An organization has any number of entities (legal companies). Each has its own chart of accounts,
fiscal years and periods, parties, documents, bank accounts, VAT settings and ledger with its own
hash chain. Operations on one entity carry its code in the path (
/v1/entities/CH01/...) or in the body ("entity": "CH01"). Organization-wide are keys, users, the approval policy, the approval queue and the audit trail. - One person or integration can work across entities with a single key; reports and documents are per entity and in the entity's functional currency (documents also in their own currency).
- The platform is separate.
/v1/platform(provision, list, suspend, reactivate organizations) takes only a platform key (erpp_...), which is accepted nowhere else; a tenant's key or session gets401there. A suspended organization is read-only: reads work, anything that changes state answers403with codesuspended(getting started).
Audit and correlation
Failed authentications, refusals by the approval policy and by four eyes, and key and user changes
are also recorded as security events (the security_event table; an operator reads them, the
API does not serve them). After repeated failed authentications from one address the API answers
429 with Retry-After for a while, even for a valid key; wait and retry.
Every write is audited with the actor, the key (key:<prefix>), the request id and the idempotency
key: GET /v1/audit-events?object_type=...&object_id=.... Send X-Request-Id (up to 200
characters) to tie your own logs to the audit trail; one is generated when you do not.
GET /v1/entities/{entity}/chain-verification recomputes the tamper-evident hash chain of all
posted entries of an entity.
data-vNext
The reference-data API (rates, FX, calendars, depreciation, standards; reference)
is a separate service with its own address ($DATAVNEXT_URL) and simpler conventions:
- Read-only data and calculations. Every operation is a
GET, except threePOSTcalculations (tax determination, depreciation schedule, decision table evaluation) that store nothing. No idempotency keys, noETag, no scopes. - Authentication: the hosted service requires an API key (
X-API-Key: <key>orAuthorization: Bearer <key>); a local server started withDATAVNEXT_AUTH=noneneeds none.GET /health,/docsand/openapi.jsonare public. A missing or wrong key answers401{"detail": "missing or invalid API key"}. - Envelope: answers are
{"data": ..., "meta": {...}}.metaholds thedataset_versionthe answer came from,as_ofandknown_atwhen used, thesources(authority, URL, legal reference, verification date) andnotes. - Effective dating: pass
as_offor the law in force on a date andknown_atfor the dataset as it was published on a date.dataset_version(a query parameter, or theX-Dataset-Versionheader) pins any request to an archived version of the dataset: that is how an ERP records and reproduces what it relied on. - Decimals are strings, as in the ERP. Dates are ISO 8601.
- Errors:
{"detail": "..."}with404for something that does not exist (a jurisdiction, a rate series, an unknown dataset version) and422for a request the service cannot answer (an invalid combination, a missing input of a decision table), or FastAPI's{"detail": [...]}list for a malformed parameter.