StabisoDocs

REST API

Customers and vendors: create, activate, change bank details

Customers and vendors are the master data every invoice and payment depends on, and the place where payment fraud starts: a fake vendor, or a real vendor whose IBAN is swapped. data-vNext therefore applies the four-eyes principle to them. This walkthrough creates a vendor (which starts as a draft), shows what a draft cannot do, has a second person activate it, changes the bank details of an existing vendor (a change that also waits for a second person), and then covers contacts, payment terms, cash discount terms and the filters of the list. It is for a client that maintains master data, for example an onboarding form or a supplier-portal integration.

Before you start

  • Scopes: read for lists and cards, draft to create and change parties, approve to decide requests of other people, admin to issue API keys.
  • The four-eyes rule compares people, not keys. A person is a user of the organization: an admin invites them with POST /v1/users, they sign in once, and a user with the admin role creates an API key for themselves with POST /v1/api-keys, using the browser session. That key counts as the person who created it. Another key of the same person, or the same person's browser session, is still that person, so nobody gets a second pair of eyes by minting a second key.
  • The organization's very first API key is issued by the system when the organization is created. It belongs to no person: it may set up, create and request things, but it can never approve a request, activate a vendor, release payments or finalise a VAT return, and a key created with it is a system key too.
  • This demo organization already has two people with keys, prepared the way described above: $ALICE_KEY (Alice, scopes read draft post approve admin) and $BOB_KEY (Bob, scopes read draft post approve). $ERP_KEY is the organization's first, system-issued key.
  • The demo has the entity CH01 with the customers C-CH, C-DE, C-FR, C-SE, C-US and the vendors V-BM (Büro Material AG, with an IBAN), V-CLOUD and V-IT, all active. Master data that enters through the API behaves differently, as this page shows.

0. Set the controls mode to four-eyes

New organizations default to the AI reviewer, which runs checks and auto-activates vendors that pass. This walkthrough shows the four-eyes path (a second person decides), so switch first:

curl -X PUT "$ERP_URL/v1/settings/controls" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"controls": "four_eyes"}'
{"controls": "four_eyes"}

1. Alice creates a vendor

A vendor is identified by kind and code within an entity. The IBAN is checked and stored without spaces. Because a person created it and the default approval policy asks for a second person to activate new vendors, the vendor is created as a draft and an activation request is opened for a second person. (A new customer would be active at once.)

curl -X POST "$ERP_URL/v1/entities/CH01/parties" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: cv-vendor-1" \
  -H "Content-Type: application/json" \
  -d '{"kind": "vendor", "code": "V-ALPEN", "name": "Alpen Bürobedarf AG", "country": "CH", "currency": "CHF", "iban": "CH08 0483 5098 7654 3210 0", "language": "de", "street": "Seestrasse", "building_number": "12", "postal_code": "6003", "town": "Luzern"}'
{"kind": "vendor", "code": "V-ALPEN", "status": "draft", "iban": "CH0804835098765432100", "language": "de", "payment_terms_days": 30}

Notice that the IBAN comes back without spaces and that the status is draft.

2. Read the card: pending request and payment blockers

The card is the full picture of a party. pending_actions and pending list the requests waiting for a second person, with who asked and why; payment_blockers lists the reasons no payment to the vendor can be released now, each coded like an error. Capture the approval_id: it identifies the activation request.

curl -X GET "$ERP_URL/v1/entities/CH01/parties/V-ALPEN?kind=vendor&as_of=2026-09-30" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "code": "V-ALPEN",
  "status": "draft",
  "pending_actions": ["activate_party"],
  "pending": [
    {
      "approval_id": "<uuid>",
      "action": "activate_party",
      "requested_by": {"type": "human"},
      "reason": "Four eyes: the person who created a customer or vendor cannot activate it."
    }
  ],
  "created_by": {"type": "human"},
  "activated_by": null,
  "balances": [],
  "payment_blockers": [
    {
      "code": "party_not_activated",
      "message": "Alpen Bürobedarf AG is waiting for a second person to activate it",
      "details": {"party": "Alpen Bürobedarf AG"}
    }
  ]
}

The card is addressed by code or id. kind is needed only when a customer and a vendor share a code (otherwise the answer is 409).

3. A draft vendor cannot be booked

Documents of a draft party are neither submitted nor posted. Booking a bill for the new vendor is refused with 409 party_not_active: the answer names the party and its status, and its hint is the call that activates it (a second person makes it). (Create the bill with ?submit=false to keep it as a draft until the vendor is active.)

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: cv-bill-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "purchase_bill", "party": "V-ALPEN", "document_date": "2026-09-15", "external_number": "ALPEN-2026-117", "lines": [{"description": "Office chairs", "quantity": "4", "unit_price": "250", "account": "6500"}]}'
{"error": {"code": "party_not_active", "party": "V-ALPEN", "party_kind": "vendor", "party_status": "draft", "hint": "POST /v1/entities/CH01/parties/V-ALPEN/activate?kind=vendor"}}

The database refuses the same thing as well, so a draft party cannot be booked around the API.

4. Add language, payment terms, cash discount terms and a contact

Master data that does not touch money changes at once, also on a draft. Omitted fields stay as they are; an empty string clears a text field. The payment terms give the due date of the vendor's bills, and the cash discount terms (Skonto) are copied onto each bill: here 2% if paid within 10 days, otherwise net in 45 days. The discount period may not be longer than the payment terms.

curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-ALPEN?kind=vendor" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"language": "fr", "payment_terms_days": 45, "cash_discount_percent": "2", "cash_discount_days": 10}'
{
  "status": "draft",
  "language": "fr",
  "payment_terms_days": 45,
  "cash_discount_percent": "2",
  "cash_discount_days": 10,
  "pending_actions": ["activate_party"]
}

The people at a customer or vendor are contacts of their own (contacts): each is added, changed and erased by itself, and the card lists them.

curl -X POST "$ERP_URL/v1/contacts" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: cv-contact-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "party": "V-ALPEN", "kind": "vendor", "first_name": "Anna", "last_name": "Keller", "role": "Accounts receivable", "email": "anna.keller@alpen-buero.example", "phone": "+41 41 555 01 02"}'
{
  "party_code": "V-ALPEN",
  "name": "Anna Keller",
  "role": "Accounts receivable",
  "email": "anna.keller@alpen-buero.example",
  "erased": false
}

PATCH needs the kind query parameter (a code may exist for a customer and a vendor), and it answers with the card.

5. Change the bank details of an existing vendor

The seeded vendor V-BM is active and has an IBAN. New bank details are never applied at once: the PATCH is accepted, the IBAN stays as it is, and a request for a second person is opened. Until it is decided, payments to the vendor are blocked. Other fields in the same PATCH (here none) would still change immediately. Capture the id of this request too.

curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-BM?kind=vendor" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"iban": "CH03 0070 0110 0001 2345 6"}'
{
  "code": "V-BM",
  "status": "active",
  "iban": "CH4431999123000889012",
  "pending_actions": ["change_bank_details"],
  "pending": [
    {
      "action": "change_bank_details",
      "current": {"iban": "CH4431999123000889012"},
      "proposed": {"iban": "CH0300700110000123456"}
    }
  ],
  "payment_blockers": [{"code": "party_bank_change_waiting", "details": {"party": "Büro Material AG"}}]
}

Notice that iban still has the old value, and that the card shows both the current and the proposed bank details, so the person who decides sees exactly what changes. A bill that names another payment account than the vendor's IBAN or its bank_accounts is not released either, until that account is added to the vendor and confirmed in the same way.

6. The approval queue

GET /v1/approvals lists the requests waiting for a decision, oldest first. This is the list the second person works through: each entry says what it is about (object_type, object_id), what is asked (action), who asked and a summary. Bob reads it.

curl -X GET "$ERP_URL/v1/approvals" \
  -H "Authorization: Bearer $BOB_KEY"
[
  {
    "object_type": "party",
    "action": "activate_party",
    "status": "pending",
    "summary": {"entity": "CH01", "party_code": "V-ALPEN", "party_kind": "vendor"}
  },
  {
    "object_type": "party",
    "action": "change_bank_details",
    "status": "pending",
    "summary": {"entity": "CH01", "party_code": "V-BM", "current": {"iban": "CH4431999123000889012"}, "proposed": {"iban": "CH0300700110000123456"}}
  }
]

7. List and filter

GET /v1/entities/{entity}/parties lists customers and vendors by code with their open balances and pending_actions. The filters combine with AND: kind, status (active or draft), country (ISO code), vat_number (any part; spaces, dots and case are ignored) and q (text in the name, the VAT number, the start of the code or of the town).

The drafts of the entity: only the vendor from step 1.

curl -X GET "$ERP_URL/v1/entities/CH01/parties?kind=vendor&status=draft&as_of=2026-09-30" \
  -H "Authorization: Bearer $BOB_KEY"
[{"kind": "vendor", "code": "V-ALPEN", "status": "draft", "pending_actions": ["activate_party"]}]

The active vendors (the first three are the company card's issuer and the two people paid back for their expenses), with the change request on V-BM visible in the list:

curl -X GET "$ERP_URL/v1/entities/CH01/parties?kind=vendor&status=active&as_of=2026-09-30" \
  -H "Authorization: Bearer $BOB_KEY"
[
  {"code": "V-00001", "pending_actions": []},
  {"code": "V-00002", "pending_actions": []},
  {"code": "V-00003", "pending_actions": []},
  {"code": "V-BM", "pending_actions": ["change_bank_details"]},
  {"code": "V-CLOUD", "pending_actions": []},
  {"code": "V-IT", "pending_actions": []},
  {"code": "V-SCAN", "pending_actions": []}
]

Free text and country:

curl -X GET "$ERP_URL/v1/entities/CH01/parties?kind=vendor&q=material&as_of=2026-09-30" \
  -H "Authorization: Bearer $BOB_KEY"
[{"code": "V-BM", "name": "Büro Material AG", "town": "Biel"}]
curl -X GET "$ERP_URL/v1/entities/CH01/parties?country=IE&vat_number=6388047&as_of=2026-09-30" \
  -H "Authorization: Bearer $BOB_KEY"
[{"kind": "vendor", "code": "V-CLOUD", "country": "IE"}]

8. Who may not decide

A request is decided by a human with the approve scope who is a known person and not the person who made the request. Three attempts fail, each with 403 and a message that says why (four_eyes for one's own request, forbidden for a key that belongs to no person).

Alice, who created the vendor and so asked for its activation, cannot approve it:

curl -X POST "$ERP_URL/v1/approvals/$ACTIVATION_ID/approve" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Looks fine to me"}'
{"error": {"code": "four_eyes", "message": "four-eyes principle: you cannot approve your own request; a second person must approve", "action": "approve"}}

Nor does a second key help. Alice issues herself another key with the approve scope (an admin may issue keys); it is a different key, but it was created by Alice, so it is Alice.

curl -X POST "$ERP_URL/v1/api-keys" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "alice laptop", "actor_type": "human", "scopes": ["read", "approve"]}'
{"id": "<uuid>", "key": "<any>", "actor_type": "human", "scopes": ["read", "approve"]}

The key is shown once, in key; the server keeps only its hash.

curl -X POST "$ERP_URL/v1/approvals/$ACTIVATION_ID/approve" \
  -H "Authorization: Bearer $ALICE_SECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
{"error": {"code": "four_eyes", "message": "four-eyes principle: you cannot approve your own request; a second person must approve", "action": "approve"}}

The organization's first key has every scope, but it belongs to nobody:

curl -X POST "$ERP_URL/v1/approvals/$ACTIVATION_ID/approve" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
{"error": {"code": "forbidden", "message": "a key the system issued (such as the organization's first admin key) belongs to no known person and cannot approve; sign in as a person, or use a key a person created"}}

9. Bob activates the vendor

Bob is a different person with the approve scope, so his decision counts. The optional comment is kept in the audit trail. (POST /v1/approvals/{id}/reject declines the request instead and the vendor stays a draft.)

curl -X POST "$ERP_URL/v1/approvals/$ACTIVATION_ID/approve" \
  -H "Authorization: Bearer $BOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "UID and IBAN checked against the supplier contract"}'
{
  "object_type": "party",
  "action": "activate_party",
  "status": "approved",
  "decided_by": {"type": "human"},
  "comment": "UID and IBAN checked against the supplier contract"
}

The card now shows who activated the vendor and when, no pending request and no payment blockers.

curl -X GET "$ERP_URL/v1/entities/CH01/parties/V-ALPEN?kind=vendor&as_of=2026-09-30" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "status": "active",
  "pending_actions": [],
  "pending": [],
  "created_by": {"type": "human"},
  "activated_by": {"type": "human"},
  "activated_at": "<date-time>",
  "payment_blockers": []
}

The bill that was refused in step 3 now books:

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: cv-bill-2" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "purchase_bill", "party": "V-ALPEN", "document_date": "2026-09-15", "external_number": "ALPEN-2026-117", "lines": [{"description": "Office chairs", "quantity": "4", "unit_price": "250", "account": "6500"}]}'
{"document": {"status": "posted", "party_code": "V-ALPEN", "net_total": "1000.00"}}

A person with the approve scope who did not create a draft party can also activate it directly with POST /v1/entities/{entity}/parties/{party}/activate?kind=vendor; for anyone else that call only opens a request.

10. Bob approves the new bank details

The change request for V-BM is decided the same way. Once it is approved the new IBAN is applied and the payment blocker disappears.

curl -X POST "$ERP_URL/v1/approvals/$BANK_ID/approve" \
  -H "Authorization: Bearer $BOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Called the supplier on the number from the contract"}'
{"object_type": "party", "action": "change_bank_details", "status": "approved"}
curl -X GET "$ERP_URL/v1/entities/CH01/parties/V-BM?kind=vendor&as_of=2026-09-30" \
  -H "Authorization: Bearer $ALICE_KEY"
{"code": "V-BM", "iban": "CH0300700110000123456", "pending_actions": [], "payment_blockers": []}

What can go wrong

A code can be used once per kind in an entity.

curl -X POST "$ERP_URL/v1/entities/CH01/parties" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: cv-vendor-dup" \
  -H "Content-Type: application/json" \
  -d '{"kind": "vendor", "code": "V-ALPEN", "name": "Alpen Bürobedarf AG", "country": "CH", "currency": "CHF"}'
{"error": {"code": "vendor_exists"}}

Activating a party that is already active is refused (409); a repeated call changes nothing. While an activation request is still waiting, the 409 carries party_activation_waiting instead.

curl -X POST "$ERP_URL/v1/entities/CH01/parties/V-ALPEN/activate?kind=vendor" \
  -H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "party_already_active"}}

An invalid IBAN is refused with 422 before any request is opened.

curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-IT?kind=vendor" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"iban": "CH00 1234"}'
{"error": {"code": "iban_invalid"}}

Agents never change bank details or activate parties. An agent key (it cannot hold the approve or admin scope) is refused with 403:

curl -X POST "$ERP_URL/v1/api-keys" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "bookkeeping agent", "actor_type": "agent", "scopes": ["read", "draft"]}'
{"id": "<uuid>", "actor_type": "agent", "scopes": ["read", "draft"]}
curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-IT?kind=vendor" \
  -H "Authorization: Bearer $AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"iban": "CH03 0070 0110 0001 2345 6"}'
{"error": {"code": "forbidden"}}

Reference

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