StabisoDocs

REST API

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

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) are multipart/form-data with a form field file.
  • 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 answers 204 without a body; everything else 200.
  • Paths are /v1/<plural-noun>, and /v1/entities/{entity}/... for what belongs to one entity ({entity} is its code, for example CH01). Actions on a resource are POST verbs such as /v1/documents/{id}/submit.
  • /v1 changes 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-02 POST /v1/periods/{id}/reopen started requiring a body {"reason": "..."} (a control gap otherwise), and on 2026-10-12 data-vNext's POST /v1/fixed-assets/depreciation-schedule (and the MCP tool depreciation_schedule) started returning each of data.notes as an object {code, details, text} instead of an English string, so that the notes can be worded in the reader's language (text keeps 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), PATCH and DELETE are 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 as If-Match: "document-3" with the change. If-Match is 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 the current_version in 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-H2 or 2026.
  • 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 a cursor parameter 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 on 502 and 503.

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 gets 401 there. A suspended organization is read-only: reads work, anything that changes state answers 403 with code suspended (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 three POST calculations (tax determination, depreciation schedule, decision table evaluation) that store nothing. No idempotency keys, no ETag, no scopes.
  • Authentication: the hosted service requires an API key (X-API-Key: <key> or Authorization: Bearer <key>); a local server started with DATAVNEXT_AUTH=none needs none. GET /health, /docs and /openapi.json are public. A missing or wrong key answers 401 {"detail": "missing or invalid API key"}.
  • Envelope: answers are {"data": ..., "meta": {...}}. meta holds the dataset_version the answer came from, as_of and known_at when used, the sources (authority, URL, legal reference, verification date) and notes.
  • Effective dating: pass as_of for the law in force on a date and known_at for the dataset as it was published on a date. dataset_version (a query parameter, or the X-Dataset-Version header) 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": "..."} with 404 for something that does not exist (a jurisdiction, a rate series, an unknown dataset version) and 422 for 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.
Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d