StabisoDocs

REST API

Onboard a new customer

This walkthrough takes a company from nothing to its first invoice in Stabiso: the platform operator provisions the organization, its first people sign in, they import the company's existing books (customers, vendors, accounts, opening balances and open items) from CSV or Excel, and they issue the first invoice. It is for the operator who runs the deployment and for whoever prepares the customer's data.

Without a platform key. The operator can do the first step in the browser, at /admin/ (the operator portal, ADR-0032): New customer sends this same request. The rest is unchanged.

These examples are not run by CI. They need a platform key and create an organization, which the demo organization of the other workflows cannot. Every step is covered by tests/erp/test_platform.py and tests/erp/test_imports.py, which prove the answers shown here. The fenced blocks use no example marker on purpose.

Who does what

Who Credential Does
The platform operator A platform key (erpp_..., $PLATFORM_KEY), scopes platform:read and platform:provision. It works only on /v1/platform Provisions the organization, suspends or reactivates it
The first administrator A Google sign-in, then an API key they create Imports the data, issues invoices
A second person (a controller) A Google sign-in Activates the imported vendors, approves what the policy sends to a second person

The second person is not optional: new vendors, activations and approvals need two people (customers and vendors), so provisioning invites both.

1. The operator provisions the organization

One request creates the organization, its entity from a template (country, functional currency, chart of accounts, VAT method, fiscal calendar; each can be overridden), every account the services need (including the opening balance clearing account), and invites the first people by email. It is all or nothing and idempotent: repeat it with the same Idempotency-Key and you get the same answer. The platform key is the only credential that works here.

curl -X POST "$ERP_URL/v1/platform/organizations" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "Idempotency-Key: onboard-muster-1" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Muster Treuhand AG",
    "admin": {"email": "petra.muster@example.com", "name": "Petra Muster"},
    "users": [{"email": "hans.keller@example.com", "name": "Hans Keller", "role": "controller"}],
    "entities": [{"template": "ch_sme", "code": "CH01", "name": "Muster Treuhand AG",
                  "vat_number": "CHE-123.456.788 MWST", "fiscal_years": [2026]}]
  }'
{
  "organization": {"org_id": "<uuid>", "name": "Muster Treuhand AG", "status": "active",
                   "created_by": {"type": "platform", "id": "platform:3fa85f64a717", "name": "operator"},
                   "created_at": "2026-10-02T09:15:57Z", "status_changed_at": null, "status_reason": null},
  "entities": [{"code": "CH01", "name": "Muster Treuhand AG", "country": "CH",
                "functional_currency": "CHF", "chart": "ch_kmu", "role_accounts": 27,
                "fiscal_years": [2026], "vat_method": "effective"}],
  "users": [{"id": "<uuid>", "email": "petra.muster@example.com", "scopes": ["admin", "approve", "draft", "post", "read"], "status": "invited"},
            {"id": "<uuid>", "email": "hans.keller@example.com", "scopes": ["approve", "draft", "post", "read"], "status": "invited"}]
}

Nothing is e-mailed and no API key is created. The audit trail of the new organization records org.create, entity.create and user.invite by platform:<prefix>, and the platform records a security event. GET /v1/platform/organizations lists organizations with their status.

Templates: ch_sme (Switzerland, CHF, VAT effective method per quarter), de_sme (Germany, EUR, VAT standard method per month with a monthly ZM) and generic (send country and functional_currency). The chart of accounts is the country's standard one (ch_kmu for CH and LI, de_hgb for DE, ifrs_generic elsewhere) unless the entity names a chart; GET /v1/platform/charts?country=DE lists the charts and marks the standard one. fiscal_years defaults to the current year.

2. The first people sign in and create their keys

Petra and Hans tell the operator nothing: they open $ERP_URL/app and sign in with the address and the password whose link Google e-mailed each of them when they were invited (password_email_sent; people of the deployment's own Google Workspace domains sign in with Google instead); a fresh link is POST /v1/platform/organizations/{org_id}/admins again for an administrator, or, from Petra, POST /v1/users/{user_id}/password-email for Hans. Petra, an admin, then creates an API key for her scripts with her session (POST /v1/api-keys, cookie and X-ERP-UI: 1):

curl -X POST "$ERP_URL/v1/api-keys" -H "X-ERP-UI: 1" --cookie "erp_session=$SESSION" \
  -H "Content-Type: application/json" \
  -d '{"name": "petra: imports", "actor_type": "human", "scopes": ["read", "draft", "post", "approve", "admin"]}'
{"id": "<uuid>", "key": "erp_9f2c...", "actor_type": "human", "scopes": ["admin", "approve", "draft", "post", "read"],
 "bound_to": "user:3b7d5e0c-..."}

bound_to says the key is Petra: four eyes compare people, so a key she created counts as her. The organization has no other key, and a key created by the system would be bound to nobody and could not approve (GET /v1/me shows bound_to for any credential). Export it as $ERP_KEY.

3. Download the templates

Every import has a template with the columns documented below; format=csv (default) or xlsx, and examples=false for the header row only:

curl "$ERP_URL/v1/entities/CH01/imports/templates/vendors?format=xlsx" \
  -H "Authorization: Bearer $ERP_KEY" -o vendors.xlsx

Amounts are plain decimals with a dot (1234.50; an apostrophe as thousands separator is dropped, a comma is refused), dates YYYY-MM-DD or DD.MM.YYYY, yes/no columns yes or no. A text that starts with =, @ or a +/- that is not a phone number or a number is refused: a spreadsheet would run it as a formula when the data is exported. Every cell of a template is text a spreadsheet cannot run.

Type Required columns Optional columns
customers, vendors code, name, country, currency vat_number, business, payment_terms_days, email, iban, street, building_number, postal_code, town, language, default_account, cash_discount_percent, cash_discount_days, contact_name, contact_email, contact_phone
accounts code, name, type, report_line role, control, currency
opening_balances account, and debit or credit currency and fx_rate (a foreign-currency balance), description
open_receivables customer, number, document_date, due_date, amount kind (invoice or credit_note), currency, fx_rate, reference
open_payables vendor, number, document_date, due_date, amount the same

4. Import customers: dry run, fix, commit

Always check first. A dry run runs the real import in a savepoint, saves nothing, and lists every problem with its row (line, the header is row 1), column and message:

curl -X POST "$ERP_URL/v1/entities/CH01/imports" -H "Authorization: Bearer $ERP_KEY" \
  -F "type=customers" -F "mode=dry_run" -F "file=@customers.csv"
{
  "type": "customers", "mode": "dry_run", "entity": "CH01", "format": "csv", "filename": "customers.csv",
  "rows": 3, "ok": false, "committed": false,
  "errors": [{"line": 4, "column": "country", "message": "must be a two-letter ISO country code, e.g. CH"}],
  "warnings": [], "summary": {"created": 2, "waiting_for_activation": 0}
}

Fix the file (line 4 said Schweiz), check again until ok is true, then commit with an Idempotency-Key. A commit is all or nothing: it applies every row in one transaction, or, if any row has a problem (the same list, answered 422), changes nothing.

curl -X POST "$ERP_URL/v1/entities/CH01/imports" -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: muster-customers-1" \
  -F "type=customers" -F "mode=commit" -F "file=@customers.csv"
{"mode": "commit", "ok": true, "committed": true, "rows": 3, "errors": [],
 "summary": {"created": 3, "waiting_for_activation": 0}}

Customers are active at once. The commit is an audit event (import.commit) with the file's SHA-256.

5. Import vendors, and have them activated

Vendors are imported the same way (type=vendors). A new vendor is a draft with an activation request, and a second person must approve it before its bills can be posted or paid (customers and vendors). Petra imported them, so she cannot activate them:

{"mode": "commit", "ok": true, "committed": true, "rows": 2, "summary": {"created": 2, "waiting_for_activation": 2}}

Hans (the controller) opens Approvals in the web UI, or GET /v1/approvals and POST /v1/approvals/{id}/approve, once per vendor. Until then a bill of such a vendor is refused by the import (open_payables, row 2, column vendor: "vendor V-1 (...) is draft: a second person must activate it ..."). An organization that wants no second person on vendors changes its approval policy (activate_party for vendors) for the duration of the migration; the default stays.

6. Import accounts, opening balances and open items

Accounts (type=accounts) add accounts to the chart (admin scope, the policy's manage_accounts). Do this first if the old trial balance uses accounts the template chart lacks.

Opening balances (type=opening_balances, with as_of) post the trial balance of the old books as one balanced entry per entity, dated as_of: the first day of the new books, whose period must be open (the balances are those at the end of the day before). The file has one row per account with a debit or a credit:

account,debit,credit,description
1000,5000.00,,Kasse
1020,20000.00,,Bank
1100,10000.00,,Debitoren
2000,,4000.00,Kreditoren
2800,,31000.00,Aktienkapital
curl -X POST "$ERP_URL/v1/entities/CH01/imports" -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: muster-opening-1" \
  -F "type=opening_balances" -F "as_of=2026-01-01" -F "mode=commit" -F "file=@trial-balance.csv"
{
  "type": "opening_balances", "mode": "commit", "ok": true, "committed": true, "as_of": "2026-01-01",
  "warnings": [
    {"line": 4, "column": "account", "message": "account 1100 is the receivables or payables control account: its balance is the open_receivables import, so this row is not posted"},
    {"line": 5, "column": "account", "message": "account 2000 is the receivables or payables control account: its balance is the open_payables import, so this row is not posted"},
    {"line": null, "column": null, "message": "the opening entry waits for a second person's approval (GET /v1/approvals). A one-person organization allows `post` for the source `opening` in its approval policy (PUT /v1/approval-policy) before it imports"}
  ],
  "summary": {"created": 1, "total_debit": "25000.00", "total_credit": "31000.00",
              "clearing_difference": "-6000.00", "clearing_balance": "0.00",
              "entry": {"entry_id": "<uuid>", "status": "pending_approval", "number": null, "approval_id": "<uuid>"}}
}

The receivables and payables control accounts are not posted: their balances are the open items, below. What the posted rows do not balance goes to the opening balance clearing account (2298 in the Swiss chart): here a debit of 6,000, which is receivables less payables. The opening entry goes through the approval policy like any posting, and the default policy sends it to a second person (rule opening_balances_need_a_second_person): the answer says "status": "pending_approval" with an approval_id and a warning, the clearing balance stays 0 until Hans approves it (POST /v1/approvals/{id}/approve), and Petra cannot. A one-person organization allows post for the source opening in its approval policy (PUT /v1/approval-policy) before it imports, and the entry is posted at once. A second opening entry is refused until the first is reversed. The open items below are unchanged: they post at once.

Open receivables and payables (type=open_receivables, type=open_payables, with the same as_of) book each open invoice, bill or credit note as a posted document with its own number, dates and open gross amount, against the clearing account:

customer,number,document_date,due_date,amount
C-1,RE-2025-0101,2025-12-01,2025-12-31,6000.00
C-1,RE-2025-0102,2025-12-15,2026-01-14,4000.00
{"type": "open_receivables", "ok": true, "committed": true, "rows": 2,
 "summary": {"created": 2, "posted": 2, "amounts_by_currency": {"CHF": "10000.00"}, "clearing_balance": "-4000.00"}}

The payables file (vendor,number,..., one bill of 4,000.00 for the activated vendor V-1) brings the clearing balance to 0.00. That is the check: once the opening balances and both lists describe the same books, the clearing account is zero, the receivables and payables control accounts equal their subledgers (the close checks ar_control_reconciled and ap_control_reconciled pass) and the trial balance of the new books equals the old one. A remainder is a difference between the old trial balance and the old open items, for a person to resolve. Nothing is counted twice: the 10,000 receivables of the trial balance appear once, as the open items. VAT is not touched: open items carry no tax lines, and the VAT report and the return reconciliation leave opening entries out (a VAT account's opening balance is no movement of any return period), so the old period's VAT return stays with the old system. For a Swiss entity the import of open receivables warns about the basis: the import assumes the old books accounted for VAT on the agreed fees (vereinbarte Entgelte); on collected fees (vereinnahmte Entgelte) the VAT on these receivables falls due when they are paid and this system will not report it, so declare it with the old system or as an adjustment of the return.

curl "$ERP_URL/v1/entities/CH01/reports/trial-balance?as_of=2026-01-31" -H "Authorization: Bearer $ERP_KEY"

Rules for open items: the customer or vendor must exist and be active; the document date is not after as_of; a customer's number is unique and must not look like this system's own (INV-2026-000001); a vendor's number is unique per vendor; a foreign-currency item carries its fx_rate (or takes the ECB rate of the day); at most 2,000 rows per file (split larger ones).

7. The first invoice

The customer is in the books, so the first new invoice is an ordinary one (invoice a customer); its number starts the system's own sequence.

curl -X POST "$ERP_URL/v1/documents" -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: muster-first-invoice" -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-1", "document_date": "2026-01-15",
       "lines": [{"description": "Consulting January", "quantity": "10", "unit_price": "100"}]}'
{"document": {"status": "posted", "number": "INV-2026-000001", "total": "1081.00"},
 "submit": {"status": "posted", "policy": {"decision": "allow"}}}

8. If something is wrong

  • A commit that answers 422 changed nothing: read error.errors, fix the file, check again.
  • An import that was committed is a set of postings and records like any other: void a draft or credit a posted document with a credit note, and so on; nothing is deleted. To correct the opening entry, reverse it (POST /v1/journal-entries/{id}/reverse; a second person always approves that reversal), then import the balances again.
  • The operator can make the organization read-only while a migration is reviewed: POST /v1/platform/organizations/{org_id}/suspend with a reason (reads keep working, everything that changes state answers 403 suspended), and .../reactivate when done.

Operations used

POST /v1/platform/organizations, GET /v1/platform/organizations, POST /v1/api-keys, GET /v1/me, GET /v1/entities/{entity}/imports/templates/{type}, POST /v1/entities/{entity}/imports, GET /v1/approvals, POST /v1/documents.

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d