StabisoDocs

REST API

Receive a vendor bill as an e-invoice

A German supplier sends an XRechnung (the German e-invoice standard, in the UBL or CII XML syntax) instead of a PDF. This walkthrough checks the file, turns it into a draft purchase bill in the German entity DE01 without any manual typing, shows why the bill of a new supplier waits for a second person and how that person releases it, submits the bill (with If-Match) and reads it back, and then receives a bill from a vendor that is already known. It is for accounts-payable integrations and for finance people who want to see what the ledger does with an e-invoice.

Before you start

  • Scopes: read to check a file, draft to receive it, to submit the bill and to request a vendor's activation, approve to decide an activation request.
  • Two people. A new vendor becomes payable only when a second person confirms it, so the examples use two keys that belong to two people: $ALICE_KEY (scopes read, draft, post, approve, admin) receives the invoice, and $BOB_KEY (read, draft, post, approve) is the second person. An API key counts as the person who created it. Alice and Bob are users who were invited (POST /v1/users), signed in once and created their key with their own session (POST /v1/api-keys); in this demo that is already done. $ERP_KEY, the organization's first key, was issued by the system when the organization was created. It belongs to no person, so it can set things up and create documents, but it can never approve, activate a vendor, release a payment file or finalise VAT, and neither can a key created with it. It appears below only in a refusal.
  • The demo organization: DE01 (Demo GmbH, EUR, VAT id DE811569869) has the vendors V-RENT, V-NL and V-IE, all active. September 2026 is open.
  • The sample files are in files/: einvoice-papierhaus.xml (a CII XRechnung from a supplier DE01 does not know), einvoice-rent.xml (the rent for September from the existing vendor V-RENT), einvoice-invalid.xml (an invoice without the buyer reference German public XRechnung rules require) and not-an-invoice.xml. They are synthetic.

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. Check an invoice (nothing is stored)

POST /v1/einvoices/check parses the file, runs the EN 16931 and XRechnung rules and answers valid and the invoice as read. It needs only the read scope and stores nothing, so a client can use it as a pre-flight check. The file goes in the multipart field file.

curl -X POST "$ERP_URL/v1/einvoices/check" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -F "file=@files/einvoice-papierhaus.xml"
{
  "valid": true,
  "label": "e-invoice (XRechnung 3.0 CII)",
  "profile": "XRechnung 3.0",
  "syntax": "CII",
  "container": "xml",
  "violations": [],
  "invoice": {
    "number": "RE-2026-0815",
    "currency": "EUR",
    "totals": {"tax_exclusive": "167.50", "tax_total": "31.83", "tax_inclusive": "199.33"}
  }
}

valid is true when no fatal rule is broken. The same call on an invoice that breaks a rule answers 200 as well, with valid: false and the violations by rule id. This invoice lacks the buyer reference (the Leitweg-ID or order number) that XRechnung requires, rule BR-DE-15:

curl -X POST "$ERP_URL/v1/einvoices/check" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -F "file=@files/einvoice-invalid.xml"
{
  "valid": false,
  "violations": [{"rule": "BR-DE-15", "severity": "fatal"}]
}

Receiving an invalid invoice (the call of step 2) keeps the file as a document but books nothing; the answer has status: invalid and the same violations, so you can send them back to the supplier.

2. Receive it as a draft bill

POST /v1/documents/einvoice stores the original file and creates a draft purchase bill from it, without a language model: the lines, VAT categories, due date, buyer reference and the supplier's IBAN come from the XML. The form fields are entity (required), file (required), vendor (a vendor code; default: found by the seller's VAT id, then by IBAN), create_vendor (default true) and submit (default false). The call needs an Idempotency-Key header.

This supplier is unknown to DE01, so the vendor is created from the seller's details as a draft vendor, created by whoever imports the invoice (Alice). With submit=true the bill is submitted too, but a bill of a draft vendor is never posted: it stays a draft, and a warning says so.

curl -X POST "$ERP_URL/v1/documents/einvoice" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: einvoice-papierhaus-1" \
  -F "entity=DE01" \
  -F "submit=true" \
  -F "file=@files/einvoice-papierhaus.xml"
{
  "status": "draft",
  "valid": true,
  "label": "e-invoice (XRechnung 3.0 CII)",
  "syntax": "CII",
  "violations": [],
  "warnings": ["new vendor V-00001 created as draft from the e-invoice; review it", "..."],
  "bill_id": "<uuid>",
  "bill_kind": "purchase_bill",
  "vendor": {
    "code": "V-00001",
    "name": "Papierhaus Schmidt GmbH",
    "matched_by": "created",
    "status": "draft"
  },
  "total": "199.33",
  "currency": "EUR"
}

Notice vendor.matched_by: created and vendor.status: draft. The last warning reads not submitted: vendor V-00001 is a draft until a second person activates it. The new vendor code is built from the seller's name; the call captured it as $VENDOR.

3. Read the draft bill

The bill is an ordinary purchase bill: the supplier's invoice number is external_number, the file is attached as source_file, and provenance records how it came in.

curl "$ERP_URL/v1/documents/$BILL_ID" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "id": "<uuid>",
  "kind": "purchase_bill",
  "status": "draft",
  "party_code": "V-00001",
  "external_number": "RE-2026-0815",
  "document_date": "2026-09-15",
  "due_date": "2026-10-15",
  "currency": "EUR",
  "net_total": "167.50",
  "tax_total": "31.83",
  "total": "199.33",
  "buyer_reference": "04011000-1234512345-06",
  "number": null,
  "lines": [{"description": "Kopierpapier A4", "net_amount": "45.00"}, "..."]
}

A draft has no document number and no journal entry yet: nothing is in the ledger.

4. The new vendor must be activated by a second person

A vendor that comes in from an e-invoice is a plain draft: nobody has asked for its activation yet. The vendor card shows its state and what is waiting.

curl "$ERP_URL/v1/entities/DE01/parties/$VENDOR?kind=vendor&as_of=2026-09-30" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "code": "V-00001",
  "status": "draft",
  "country": "DE",
  "vat_number": "DE123456788",
  "iban": "DE91370400440532099100",
  "pending_actions": [],
  "pending": []
}

This is a control, not a formality. A fraudulent invoice from an unknown supplier is the classic way to get money out of a company; the rule is that the person who brought a vendor in cannot also make it payable. The vendor's bank details came from the invoice, so the second person should compare them with an independent source before activating. Until then the vendor's bills cannot be submitted:

curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
  -H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "party_not_active", "party_status": "draft"}}

POST /v1/entities/DE01/parties/{code}/activate activates the vendor at once when the caller is a person with the approve scope who did not create it. For anyone else it files an activation request for a second person. Alice created the vendor, so her call only files the request: the card then shows pending_actions: ["activate_party"] and the request's approval_id, which the call captures.

curl -X POST "$ERP_URL/v1/entities/DE01/parties/$VENDOR/activate?kind=vendor" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "code": "V-00001",
  "status": "draft",
  "pending_actions": ["activate_party"],
  "pending": [{"action": "activate_party"}]
}

The request is decided with POST /v1/approvals/{id}/approve (or .../reject) by a person who is neither the requester nor the creator of the vendor. The comparison is between people, not keys: Alice's other keys and her browser session are Alice too. Two refusals show it. Alice cannot approve her own request, and the organization's first key, which belongs to nobody, cannot decide at all. Both answer 403.

curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "approving my own request"}'
{"error": {"code": "four_eyes", "action": "approve"}}
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"comment": "the first key is nobody"}'
{"error": {"code": "forbidden"}}

Bob, a second person with the approve scope, checks the vendor's details and approves. The activation happens in the same call.

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

The card now shows who activated the vendor, and payment_blockers is empty: payments to it can be released.

curl "$ERP_URL/v1/entities/DE01/parties/$VENDOR?kind=vendor&as_of=2026-09-30" \
  -H "Authorization: Bearer $BOB_KEY"
{
  "code": "V-00001",
  "status": "active",
  "activated_by": {"type": "human"},
  "pending_actions": [],
  "pending": [],
  "payment_blockers": []
}

5. Submit the bill

Now the bill can be submitted. Read it to get its current ETag, then submit with If-Match. The header makes the submit apply only to the version you looked at (a changed bill answers 412). POST /v1/documents/{id}/submit sends the bill through the approval policy: by default a human posts it at once, an agent key posts routine amounts (up to 5000), and anything the policy holds back becomes pending_approval for a second person.

curl "$ERP_URL/v1/documents/$BILL_ID" \
  -H "Authorization: Bearer $ALICE_KEY"
{"status": "draft", "external_number": "RE-2026-0815", "total": "199.33"}
curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H 'If-Match: $ETAG'
{
  "document": {"status": "posted", "total": "199.33"},
  "submit": {"status": "posted", "policy": {"decision": "allow"}}
}

submit.policy.decision is allow: the entry was posted. With require_approval the document would be pending_approval and submit.approval_id would name the request.

6. Read the posted bill

Posting gave the bill its document number and a journal entry. open_amount is what is still unpaid; the bill now appears in the payables, and the payment-run workflow (From a bill to a payment file) can pay it.

curl "$ERP_URL/v1/documents/$BILL_ID" \
  -H "Authorization: Bearer $ALICE_KEY"
{
  "status": "posted",
  "number": "<any>",
  "journal_entry_id": "<uuid>",
  "external_number": "RE-2026-0815",
  "party_code": "V-00001",
  "net_total": "167.50",
  "tax_total": "31.83",
  "total": "199.33",
  "open_amount": "199.33",
  "source_file": {"filename": "einvoice-papierhaus.xml", "content_type": "application/xml"}
}

GET /v1/documents/{id}/file returns the stored original byte for byte, with its SHA-256 in the X-Content-SHA256 header, so the XML an auditor wants to see is always the one that came in.

7. A bill from a known vendor

When the vendor is already active, none of the above is needed. The rent invoice for September comes from the supplier DE01 already has as V-RENT. The seller's details in the file do not identify it (its VAT id is not on the vendor record), so the call names the vendor with the vendor field. Without vendor, the seller's VAT id and then its IBAN pick the vendor; an unknown seller creates a draft vendor as in step 2, and create_vendor=false answers needs_review instead. With submit=true the bill is posted at once.

curl -X POST "$ERP_URL/v1/documents/einvoice" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: einvoice-rent-1" \
  -F "entity=DE01" \
  -F "vendor=V-RENT" \
  -F "submit=true" \
  -F "file=@files/einvoice-rent.xml"
{
  "status": "posted",
  "valid": true,
  "bill_kind": "purchase_bill",
  "vendor": {"code": "V-RENT", "matched_by": "given", "status": "active"},
  "total": "2380.00",
  "currency": "EUR"
}

The 19% VAT printed in the invoice (380.00 on 2000.00) is checked against the VAT the ledger determines for a domestic German purchase on that date; a difference would be listed in warnings.

The invoice names the IBAN DE02120300000000202051 for payment, and V-RENT has no IBAN on file. The bill carries it as its payment_iban; before this bill can be paid, that account has to be added to the vendor and confirmed by a second person (see From a bill to a payment file).

What can go wrong

The same file again is refused, whatever the Idempotency-Key (the same key with the same file replays the first answer instead). So is a second file with the same vendor invoice number from the same vendor. The refusal names the bill that already exists.

curl -X POST "$ERP_URL/v1/documents/einvoice" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H "Idempotency-Key: einvoice-papierhaus-2" \
  -F "entity=DE01" \
  -F "file=@files/einvoice-papierhaus.xml"
{"error": {"code": "file_already_captured"}}

A file that is not an e-invoice at all (or is larger than 20 MB) is a 422:

curl -X POST "$ERP_URL/v1/einvoices/check" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -F "file=@files/not-an-invoice.xml"
{"error": {"code": "einvoice_unreadable"}}

An If-Match for a version that is no longer current is refused with 412 before anything else is checked. Read the document again and retry:

curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
  -H "Authorization: Bearer $ALICE_KEY" \
  -H 'If-Match: "document-999"'
{"error": {"code": "precondition_failed"}}

A bill that is already posted cannot be submitted again (409):

curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
  -H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "document_not_draft"}}

Not shown: bill capture

POST /v1/agents/bill-capture takes a PDF or an image of a bill, or an XML file, and creates the same kind of draft bill. When the upload is an XML e-invoice or a ZUGFeRD / Factur-X PDF with the XML embedded, it uses the XML and no model (source: einvoice). Only a plain PDF or image is read by a language model, which is why that call is not executed here. Its draft vendors and bills follow the same rules as above; a vendor created by an agent is activated by a human who is not the person who ran the agent.

Reference

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 6f03a5e846b3